diff --git a/.vscode/i18n-ally-custom-framework.yml b/.vscode/i18n-ally-custom-framework.yml new file mode 100644 index 000000000..f69203c30 --- /dev/null +++ b/.vscode/i18n-ally-custom-framework.yml @@ -0,0 +1,12 @@ +languageIds: + - typescript +usageMatchRegex: + - "(?:\\$\\{)?translate\\(['\"`]({key})['\"`]" +scopeRangeRegex: "useTranslation\\(\\s*\\[?\\s*['\"`](.*?)['\"`]" +derivedKeyRules: + - '{key}_zero' + - '{key}_one' + - '{key}_two' + - '{key}_few' + - '{key}_many' + - '{key}_other' diff --git a/.vscode/settings.json b/.vscode/settings.json index 511541411..8d0b5dbc4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,12 @@ "editor.codeActionsOnSave": { "source.organizeImports": "never" }, - "eslint.useFlatConfig": true + "eslint.useFlatConfig": true, + "i18n-ally.sourceLanguage": "en-US", + "i18n-ally.displayLanguage": "en-US", + "i18n-ally.localesPaths": "packages/core/src/locales", + "i18n-ally.sortKeys": true, + "i18n-ally.namespace": true, + "i18n-ally.enabledFrameworks": ["custom", "i18next"], + "i18n-ally.keystyle": "nested" } diff --git a/README.md b/README.md index 14de9d285..dc6b8eaa7 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ _React Bindings_ - [Features](#features) - [Usage](#usage) - [Using the Discord font](#using-the-discord-font) + - [Internationalization](#internationalization) + - [Initialization](#initialization) + - [Setting the language manually](#setting-the-language-manually) + - [Supported languages](#supported-languages) - [Integrations](#integrations) - [Angular](#angular) - [Important Notes](#important-notes) @@ -197,6 +201,82 @@ do so by including the CSS below: } ``` +### Internationalization + +This package uses [i18next](https://www.i18next.com/) for internationalization. +We load +[i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) +plugin to attempt to detect the user's browser language, or you can set the +language yourself as seen at +[Setting the language manually](#setting-the-language-manually). + +#### Initialization + +i18next will be initialized by importing any component internally. +Alternatively, if you want to initialize it yourself (i.e. in your application +entrypoint) you can do so with the following code: + +```ts +import '@skyra/discord-components'; +``` + +or if you only want to load i18n and not any of the component side effects: + +```ts +import '@skyra/discord-components/i18n'; +``` + +#### Setting the language manually + +We expose the function `setI18nLanguage` which can be used to manually set the +language of i18next. You can use one of the following: + +```ts +import { setI18nLanguage } from '@skyra/discord-components'; +``` + +```ts +import { setI18nLanguage } from '@skyra/discord-components/i18n/utils'; +``` + +#### Supported languages + +The list of languages supported are matched to the list of languages the Discord +client supports. The list is as follows: + +- `bg`: Bulgarian +- `cs`: Czech +- `da`: Danish +- `de`: German +- `el`: Greek +- `en-GB`: English (British) +- `en-US`: English (American) +- `es-419`: Spanish (Latin America) +- `es-ES`: Spanish (Spain) +- `fi`: Finnish +- `fr`: French +- `hi`: Hindi +- `hr`: Croatian +- `hu`: Hungarian +- `id`: Indonesian +- `it`: Italian +- `ja`: Japanese +- `ko`: Korean +- `lt`: Lithuanian +- `nl`: Dutch +- `no`: Norwegian +- `pl`: Polish +- `pt-BR`: Portuguese (Brazil) +- `ro`: Romanian +- `ru`: Russian +- `sv-SE`: Swedish +- `th`: Thai +- `tr`: Turkish +- `uk`: Ukrainian +- `vi`: Vietnamese +- `zh-CN`: Chinese (Simplified) +- `zh-TW`: Chinese (Traditional) + ### Integrations #### Angular diff --git a/assets/readme-templates/CORE_USAGE.md b/assets/readme-templates/CORE_USAGE.md index b89b6b4df..1f269043d 100644 --- a/assets/readme-templates/CORE_USAGE.md +++ b/assets/readme-templates/CORE_USAGE.md @@ -61,6 +61,82 @@ do so by including the CSS below: } ``` +### Internationalization + +This package uses [i18next](https://www.i18next.com/) for internationalization. +We load +[i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) +plugin to attempt to detect the user's browser language, or you can set the +language yourself as seen at +[Setting the language manually](#setting-the-language-manually). + +#### Initialization + +i18next will be initialized by importing any component internally. +Alternatively, if you want to initialize it yourself (i.e. in your application +entrypoint) you can do so with the following code: + +```ts +import '@skyra/discord-components'; +``` + +or if you only want to load i18n and not any of the component side effects: + +```ts +import '@skyra/discord-components/i18n'; +``` + +#### Setting the language manually + +We expose the function `setI18nLanguage` which can be used to manually set the +language of i18next. You can use one of the following: + +```ts +import { setI18nLanguage } from '@skyra/discord-components'; +``` + +```ts +import { setI18nLanguage } from '@skyra/discord-components/i18n/utils'; +``` + +#### Supported languages + +The list of languages supported are matched to the list of languages the Discord +client supports. The list is as follows: + +- `bg`: Bulgarian +- `cs`: Czech +- `da`: Danish +- `de`: German +- `el`: Greek +- `en-GB`: English (British) +- `en-US`: English (American) +- `es-419`: Spanish (Latin America) +- `es-ES`: Spanish (Spain) +- `fi`: Finnish +- `fr`: French +- `hi`: Hindi +- `hr`: Croatian +- `hu`: Hungarian +- `id`: Indonesian +- `it`: Italian +- `ja`: Japanese +- `ko`: Korean +- `lt`: Lithuanian +- `nl`: Dutch +- `no`: Norwegian +- `pl`: Polish +- `pt-BR`: Portuguese (Brazil) +- `ro`: Romanian +- `ru`: Russian +- `sv-SE`: Swedish +- `th`: Thai +- `tr`: Turkish +- `uk`: Ukrainian +- `vi`: Vietnamese +- `zh-CN`: Chinese (Simplified) +- `zh-TW`: Chinese (Traditional) + ### Integrations #### Angular diff --git a/assets/readme-templates/REACT_USAGE.md b/assets/readme-templates/REACT_USAGE.md index 16ef5ecdd..143ee1169 100644 --- a/assets/readme-templates/REACT_USAGE.md +++ b/assets/readme-templates/REACT_USAGE.md @@ -49,6 +49,78 @@ do so by including the CSS below: } ``` +### Internationalization + +This package uses [i18next](https://www.i18next.com/) for internationalization. +We load i18next into Lit using [lit-i18n](https://github.com/colscott/lit-i18n) +and we also add the +[i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) +plugin to attempt to detect the user's browser language. + +#### Initialization + +i18next will be initialized by importing any component internally. +Alternatively, if you want to initialize it yourself (i.e. in your application +entrypoint) you can do so with the following code: + +```ts +import '@skyra/discord-components-react'; +``` + +#### Setting the language manually + +We expose the function `setI18nLanguage` which can be used to manually set the +language of i18next. You can use one of the following: + +```ts +import { setI18nLanguage } from '@skyra/discord-components-react'; +``` + +#### Integrating with `react-i18next` + +If you want to integrate this with +[`react-i18next`](https://github.com/i18next/react-i18next), you can simply +initialize i18next as shown above, then import either `useTranslation` or +`Trans` from `react-i18next` and use them as you would normally. + +#### Supported languages + +The list of languages supported are matched to the list of languages the Discord +client supports. The list is as follows: + +- `bg`: Bulgarian +- `cs`: Czech +- `da`: Danish +- `de`: German +- `el`: Greek +- `en-GB`: English (British) +- `en-US`: English (American) +- `es-419`: Spanish (Latin America) +- `es-ES`: Spanish (Spain) +- `fi`: Finnish +- `fr`: French +- `hi`: Hindi +- `hr`: Croatian +- `hu`: Hungarian +- `id`: Indonesian +- `it`: Italian +- `ja`: Japanese +- `ko`: Korean +- `lt`: Lithuanian +- `nl`: Dutch +- `no`: Norwegian +- `pl`: Polish +- `pt-BR`: Portuguese (Brazil) +- `ro`: Romanian +- `ru`: Russian +- `sv-SE`: Swedish +- `th`: Thai +- `tr`: Turkish +- `uk`: Ukrainian +- `vi`: Vietnamese +- `zh-CN`: Chinese (Simplified) +- `zh-TW`: Chinese (Traditional) + ### Vite #### Live Demo diff --git a/packages/core/README.md b/packages/core/README.md index a6f4377e2..fbc62f0b1 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -41,6 +41,10 @@ _React Bindings_ - [Installation](#installation) - [Usage](#usage) - [Using the Discord font](#using-the-discord-font) + - [Internationalization](#internationalization) + - [Initialization](#initialization) + - [Setting the language manually](#setting-the-language-manually) + - [Supported languages](#supported-languages) - [Integrations](#integrations) - [Angular](#angular) - [Important Notes](#important-notes) @@ -205,6 +209,82 @@ do so by including the CSS below: } ``` +### Internationalization + +This package uses [i18next](https://www.i18next.com/) for internationalization. +We load +[i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) +plugin to attempt to detect the user's browser language, or you can set the +language yourself as seen at +[Setting the language manually](#setting-the-language-manually). + +#### Initialization + +i18next will be initialized by importing any component internally. +Alternatively, if you want to initialize it yourself (i.e. in your application +entrypoint) you can do so with the following code: + +```ts +import '@skyra/discord-components'; +``` + +or if you only want to load i18n and not any of the component side effects: + +```ts +import '@skyra/discord-components/i18n'; +``` + +#### Setting the language manually + +We expose the function `setI18nLanguage` which can be used to manually set the +language of i18next. You can use one of the following: + +```ts +import { setI18nLanguage } from '@skyra/discord-components'; +``` + +```ts +import { setI18nLanguage } from '@skyra/discord-components/i18n/utils'; +``` + +#### Supported languages + +The list of languages supported are matched to the list of languages the Discord +client supports. The list is as follows: + +- `bg`: Bulgarian +- `cs`: Czech +- `da`: Danish +- `de`: German +- `el`: Greek +- `en-GB`: English (British) +- `en-US`: English (American) +- `es-419`: Spanish (Latin America) +- `es-ES`: Spanish (Spain) +- `fi`: Finnish +- `fr`: French +- `hi`: Hindi +- `hr`: Croatian +- `hu`: Hungarian +- `id`: Indonesian +- `it`: Italian +- `ja`: Japanese +- `ko`: Korean +- `lt`: Lithuanian +- `nl`: Dutch +- `no`: Norwegian +- `pl`: Polish +- `pt-BR`: Portuguese (Brazil) +- `ro`: Romanian +- `ru`: Russian +- `sv-SE`: Swedish +- `th`: Thai +- `tr`: Turkish +- `uk`: Ukrainian +- `vi`: Vietnamese +- `zh-CN`: Chinese (Simplified) +- `zh-TW`: Chinese (Traditional) + ### Integrations #### Angular diff --git a/packages/core/package.json b/packages/core/package.json index a2cae7a74..51972c614 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,6 +11,8 @@ "customElements": "custom-elements.json", "exports": { ".": "./dist/index.js", + "./i18n": "./dist/i18n/init.js", + "./i18n/utils": "./dist/i18n/utils.js", "./discord-action-row.js": "./dist/components/discord-action-row/DiscordActionRow.js", "./discord-attachments.js": "./dist/components/discord-attachments/DiscordAttachments.js", "./discord-audio-attachment.js": "./dist/components/discord-audio-attachment/DiscordAudioAttachment.js", @@ -59,6 +61,8 @@ }, "sideEffects": [ "./dist/index.js", + "./dist/i18n/init.js", + "./dist/i18n/utils.js", "./dist/components/discord-action-row/DiscordActionRow.js", "./dist/components/discord-attachments/DiscordAttachments.js", "./dist/components/discord-audio-attachment/DiscordAudioAttachment.js", @@ -120,6 +124,8 @@ }, "dependencies": { "@lit/context": "^1.1.2", + "i18next": "^23.14.0", + "i18next-browser-languagedetector": "^8.0.0", "lit": "^3.2.0" }, "devDependencies": { diff --git a/packages/core/src/components/discord-author-info/DiscordAuthorInfo.ts b/packages/core/src/components/discord-author-info/DiscordAuthorInfo.ts index 45968539d..7fa388594 100644 --- a/packages/core/src/components/discord-author-info/DiscordAuthorInfo.ts +++ b/packages/core/src/components/discord-author-info/DiscordAuthorInfo.ts @@ -6,6 +6,7 @@ import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; +import { translate } from '../../i18n/lit-integration.js'; import { getClanIcon } from '../../util.js'; import { messagesCompactMode, messagesLightTheme } from '../discord-messages/DiscordMessages.js'; @@ -222,8 +223,8 @@ export class DiscordAuthorInfo extends LitElement { />` )} ${when(this.bot && !this.server, () => html``)} - ${when(this.server && !this.bot, () => html`Server`)} - ${when(this.op, () => html`OP`)} + ${when(this.server && !this.bot, () => html`${translate('discord-author-info.server')}`)} + ${when(this.op, () => html`${translate('discord-author-info.op')}`)} ${when( this.compactMode, () => html`${this.author}` diff --git a/packages/core/src/components/discord-button/DiscordButton.ts b/packages/core/src/components/discord-button/DiscordButton.ts index 4375128dd..52fc6f099 100644 --- a/packages/core/src/components/discord-button/DiscordButton.ts +++ b/packages/core/src/components/discord-button/DiscordButton.ts @@ -1,3 +1,4 @@ +import i18next from 'i18next'; import { css, html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -143,16 +144,16 @@ export class DiscordButton extends LitElement { public checkType() { if (this.type) { if (typeof this.type !== 'string') { - throw new TypeError('DiscordButton `type` prop must be a string.'); + throw new TypeError(i18next.t('discord-button.errors.type-error')); } else if (!this.validButtonTypes.has(this.type)) { - throw new RangeError("DiscordButton `type` prop must be one of: 'primary', 'secondary', 'success', 'destructive'"); + throw new RangeError(i18next.t('discord-button.errors.range-error')); } } } public checkParentElement() { if (this.parentElement?.tagName.toLowerCase() !== 'discord-action-row') { - throw new DiscordComponentsError('All components must be direct children of .'); + throw new DiscordComponentsError(i18next.t('discord-button.errors.wrong-parent')); } } diff --git a/packages/core/src/components/discord-command/DiscordCommand.ts b/packages/core/src/components/discord-command/DiscordCommand.ts index a9d4f1719..726706405 100644 --- a/packages/core/src/components/discord-command/DiscordCommand.ts +++ b/packages/core/src/components/discord-command/DiscordCommand.ts @@ -5,6 +5,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; import { avatars, profiles } from '../../config.js'; +import { translate } from '../../i18n/lit-integration.js'; import type { LightTheme, Profile } from '../../types.js'; import { messagesCompactMode, messagesLightTheme } from '../discord-messages/DiscordMessages.js'; import { DiscordReply } from '../discord-reply/DiscordReply.js'; @@ -94,7 +95,7 @@ export class DiscordCommand extends LitElement implements LightTheme { () => html`${ifDefined(profile.author)}` )} ${profile.author} - used + ${translate('discord-command.used')}
${this.command}
`; } diff --git a/packages/core/src/components/discord-custom-emoji/DiscordCustomEmoji.ts b/packages/core/src/components/discord-custom-emoji/DiscordCustomEmoji.ts index 2c8e4cd41..468b15de9 100644 --- a/packages/core/src/components/discord-custom-emoji/DiscordCustomEmoji.ts +++ b/packages/core/src/components/discord-custom-emoji/DiscordCustomEmoji.ts @@ -109,7 +109,7 @@ export class DiscordCustomEmoji extends LitElement { 'discord-custom-emoji-image': !this.embedEmoji, 'discord-custom-jumbo-emoji-image': this.jumbo })} - /> `; + />`; } } diff --git a/packages/core/src/components/discord-embed-field/DiscordEmbedField.ts b/packages/core/src/components/discord-embed-field/DiscordEmbedField.ts index 6387cafb2..097393959 100644 --- a/packages/core/src/components/discord-embed-field/DiscordEmbedField.ts +++ b/packages/core/src/components/discord-embed-field/DiscordEmbedField.ts @@ -1,4 +1,5 @@ import { consume } from '@lit/context'; +import i18next from 'i18next'; import { css, html, LitElement, type TemplateResult } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -86,7 +87,7 @@ export class DiscordEmbedField extends LitElement implements LightTheme { if (this.inlineIndex) { const inlineIndexAsNumber = Number(this.inlineIndex); if (!Number.isNaN(inlineIndexAsNumber) && !this.validInlineIndices.has(inlineIndexAsNumber)) { - throw new RangeError('DiscordEmbedField `inlineIndex` prop must be one of: 1, 2, or 3'); + throw new RangeError(i18next.t('discord-embed-field.errors.range-error')); } } } diff --git a/packages/core/src/components/discord-header/DiscordHeader.ts b/packages/core/src/components/discord-header/DiscordHeader.ts index 4c2810657..94266cb46 100644 --- a/packages/core/src/components/discord-header/DiscordHeader.ts +++ b/packages/core/src/components/discord-header/DiscordHeader.ts @@ -1,3 +1,4 @@ +import i18next from 'i18next'; import { css, html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; @@ -51,7 +52,7 @@ export class DiscordHeader extends LitElement { public checkLevel() { if (this.level < 1 || this.level > 3) { - throw new RangeError('The level property must be a number between 1 and 3 (inclusive)'); + throw new RangeError(i18next.t('discord-header.errors.range-error')); } } diff --git a/packages/core/src/components/discord-input-text/DiscordInputText.ts b/packages/core/src/components/discord-input-text/DiscordInputText.ts index f1006de1a..895135ead 100644 --- a/packages/core/src/components/discord-input-text/DiscordInputText.ts +++ b/packages/core/src/components/discord-input-text/DiscordInputText.ts @@ -1,9 +1,11 @@ import { consume } from '@lit/context'; +import i18next from 'i18next'; import { css, html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { when } from 'lit/directives/when.js'; +import { translate } from '../../i18n/lit-integration.js'; import { DiscordComponentsError } from '../../util.js'; import { messagesLightTheme } from '../discord-messages/DiscordMessages.js'; @@ -321,8 +323,11 @@ export class DiscordInputText extends LitElement { ${when( this.hasWarning, () => - html` - - Must be between ${this.minLength} and ${this.maxLength} in length.${translate('discord-input-text.must-be-between-length', { + minLength: this.minLength, + maxLength: this.maxLength + })}` )} @@ -382,7 +387,7 @@ export class DiscordInputText extends LitElement {
!
- Please fill out this field. + ${translate('discord-input-text.required-field')} ` )} @@ -394,8 +399,11 @@ export class DiscordInputText extends LitElement {
!
Increase this text to ${this.minLength} characters or more. You are currently using ${this.calculatedCharactersCount} - characters${translate('discord-input-text.increase-length', { + minLength: this.minLength, + calculatedCharactersCount: this.calculatedCharactersCount, + count: this.calculatedCharactersCount + })} ` @@ -406,7 +414,7 @@ export class DiscordInputText extends LitElement { this.hasWarning && this.valueIsNotNullOrUndefined(this.minLength), () => html`Must be ${this.minLength} characters or more in length.${translate('discord-input-text.required-minimum-length', { minLength: this.minLength })}` )} @@ -419,17 +427,17 @@ export class DiscordInputText extends LitElement { private checkNeededArgument() { if (!this.label) { - throw new DiscordComponentsError('Label is required to input text'); + throw new DiscordComponentsError(i18next.t('discord-input-text.errors.label-required')); } else if (!this.type) { - throw new DiscordComponentsError('Type is required to input text'); + throw new DiscordComponentsError(i18next.t('discord-input-text.errors.type-required')); } } private checkType() { if (typeof this.type !== 'string') { - throw new TypeError('DiscordInputText `type` prop must be a string.'); + throw new TypeError(i18next.t('discord-input-text.errors.type-must-be-string')); } else if (!this.validInputTextTypes.has(this.type)) { - throw new RangeError("DiscordInputText `type` prop must be one of: 'short', 'paragraph'"); + throw new RangeError(i18next.t('discord-input-text.errors.type-required-variant')); } } diff --git a/packages/core/src/components/discord-list-item/DiscordListItem.ts b/packages/core/src/components/discord-list-item/DiscordListItem.ts index 4d21468bd..1e03630b0 100644 --- a/packages/core/src/components/discord-list-item/DiscordListItem.ts +++ b/packages/core/src/components/discord-list-item/DiscordListItem.ts @@ -1,3 +1,4 @@ +import i18next from 'i18next'; import { css, html, LitElement } from 'lit'; import { customElement } from 'lit/decorators.js'; import { DiscordComponentsError } from '../../util.js'; @@ -18,9 +19,7 @@ export class DiscordListItem extends LitElement { this.parentElement?.tagName.toLowerCase() !== 'discord-unordered-list' && this.parentElement?.tagName.toLowerCase() !== 'discord-ordered-list' ) { - throw new DiscordComponentsError( - 'All components must be direct children of or .' - ); + throw new DiscordComponentsError(i18next.t('discord-list-item.errors.wrong-parent')); } } diff --git a/packages/core/src/components/discord-modal/DiscordModal.ts b/packages/core/src/components/discord-modal/DiscordModal.ts index fdc0874a4..268b64d95 100644 --- a/packages/core/src/components/discord-modal/DiscordModal.ts +++ b/packages/core/src/components/discord-modal/DiscordModal.ts @@ -5,6 +5,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, ref, type Ref } from 'lit/directives/ref.js'; import { avatars, profiles } from '../../config.js'; +import { translate } from '../../i18n/lit-integration.js'; import type { LightTheme, Profile } from '../../types.js'; import { DiscordInputText } from '../discord-input-text/DiscordInputText.js'; import { messagesLightTheme } from '../discord-messages/DiscordMessages.js'; @@ -626,7 +627,7 @@ export class DiscordModal extends LitElement implements LightTheme { })} > diff --git a/packages/core/src/components/discord-ordered-list/DiscordOrderedList.ts b/packages/core/src/components/discord-ordered-list/DiscordOrderedList.ts index 28b70c64b..3dca4ab47 100644 --- a/packages/core/src/components/discord-ordered-list/DiscordOrderedList.ts +++ b/packages/core/src/components/discord-ordered-list/DiscordOrderedList.ts @@ -1,3 +1,4 @@ +import i18next from 'i18next'; import { css, html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { DiscordComponentsError } from '../../util.js'; @@ -44,9 +45,7 @@ export class DiscordOrderedList extends LitElement { }); if (!allChildrenAreListItems) { - throw new DiscordComponentsError( - 'All direct children inside of a components must be one of , , or .' - ); + throw new DiscordComponentsError(i18next.t('discord-ordered-list.errors.invalid-children')); } } diff --git a/packages/core/src/components/discord-reply/DiscordReply.ts b/packages/core/src/components/discord-reply/DiscordReply.ts index 6334f864e..5829bcf1d 100644 --- a/packages/core/src/components/discord-reply/DiscordReply.ts +++ b/packages/core/src/components/discord-reply/DiscordReply.ts @@ -5,6 +5,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; import { avatars, profiles } from '../../config.js'; +import { translate } from '../../i18n/lit-integration.js'; import type { LightTheme, Profile } from '../../types.js'; import { getClanIcon } from '../../util.js'; import { messagesCompactMode, messagesLightTheme } from '../discord-messages/DiscordMessages.js'; @@ -390,10 +391,10 @@ export class DiscordReply extends LitElement implements LightTheme { const profileTag = html` ${when( profile.bot && !profile.server, - () => html`${profile.verified ? VerifiedTick() : ''}App` + () => html`${profile.verified ? VerifiedTick() : ''}${translate('discord-reply.app')}` )} - ${when(profile.server && !profile.bot, () => html`Server`)} - ${when(profile.op, () => html`OP`)} + ${when(profile.server && !profile.bot, () => html`${translate('discord-reply.server')}`)} + ${when(profile.op, () => html`${translate('discord-reply.op')}`)} `; return html`${when( @@ -403,7 +404,7 @@ export class DiscordReply extends LitElement implements LightTheme { )} ${when( this.deleted, - () => html`
Original message was deleted
`, + () => html`
${translate('discord-reply.original-message-deleted')}
`, () => html`${profileTag}
${when(this.edited, () => html`(edited)`)}
${when( + this.edited, + () => html`(${translate('discord-reply.edited')})` + )} ${when( this.command, diff --git a/packages/core/src/components/discord-string-select-menu-option/DiscordStringSelectMenuOption.ts b/packages/core/src/components/discord-string-select-menu-option/DiscordStringSelectMenuOption.ts index d7cb5e6af..8bb25bac9 100644 --- a/packages/core/src/components/discord-string-select-menu-option/DiscordStringSelectMenuOption.ts +++ b/packages/core/src/components/discord-string-select-menu-option/DiscordStringSelectMenuOption.ts @@ -1,4 +1,5 @@ import { consume } from '@lit/context'; +import i18next from 'i18next'; import { css, html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -85,7 +86,7 @@ export class DiscordStringSelectMenuOption extends LitElement implements LightTh public checkLabelIsProvided() { if (!this.label) { - throw new DiscordComponentsError('The label of option is required'); + throw new DiscordComponentsError(i18next.t('discord-string-select-menu-option.errors.label-required')); } } diff --git a/packages/core/src/components/discord-system-message/DiscordSystemMessage.ts b/packages/core/src/components/discord-system-message/DiscordSystemMessage.ts index 40e059d0c..3c3bdeeca 100644 --- a/packages/core/src/components/discord-system-message/DiscordSystemMessage.ts +++ b/packages/core/src/components/discord-system-message/DiscordSystemMessage.ts @@ -1,4 +1,5 @@ import { consume } from '@lit/context'; +import i18next from 'i18next'; import { css, html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { choose } from 'lit/directives/choose.js'; @@ -172,11 +173,9 @@ export class DiscordSystemMessage extends LitElement implements LightTheme { public checkType() { if (typeof this.type !== 'string') { - throw new TypeError('DiscordSystemMessage `type` prop must be a string.'); + throw new TypeError(i18next.t('discord-system-message.errors.type-error')); } else if (!['join', 'leave', 'call', 'missed-call', 'boost', 'edit', 'thread', 'pin', 'alert', 'error', 'upgrade'].includes(this.type)) { - throw new RangeError( - "DiscordSystemMessage `type` prop must be one of: 'join', 'leave', 'call', 'missed-call', 'boost', 'edit', 'thread', 'pin', 'alert', 'upgrade', 'error'" - ); + throw new RangeError(i18next.t('discord-system-message.errors.range-error')); } } diff --git a/packages/core/src/components/discord-thread-message/DiscordThreadMessage.ts b/packages/core/src/components/discord-thread-message/DiscordThreadMessage.ts index e96c6e38e..8ffc3e6da 100644 --- a/packages/core/src/components/discord-thread-message/DiscordThreadMessage.ts +++ b/packages/core/src/components/discord-thread-message/DiscordThreadMessage.ts @@ -5,6 +5,7 @@ import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { when } from 'lit/directives/when.js'; import { avatars, profiles } from '../../config.js'; +import { translate } from '../../i18n/lit-integration.js'; import type { LightTheme, Profile } from '../../types.js'; import { messagesLightTheme } from '../discord-messages/DiscordMessages.js'; import VerifiedTick from '../svgs/VerifiedTick.js'; @@ -173,9 +174,15 @@ export class DiscordThreadMessage extends LitElement implements LightTheme { return html`${ifDefined(profile.author)} ${when( profile.bot && !profile.server, - () => html` ${profile.verified ? VerifiedTick() : null} App ` + () => + html` + ${profile.verified ? VerifiedTick() : null} ${translate('discord-thread-message.app')} + ` + )} + ${when( + profile.server && !profile.bot, + () => html`${translate('discord-thread-message.server')}` )} - ${when(profile.server && !profile.bot, () => html`Server`)} ${profile.author}
diff --git a/packages/core/src/components/discord-unordered-list/DiscordUnorderedList.ts b/packages/core/src/components/discord-unordered-list/DiscordUnorderedList.ts index 543c828c6..8da5bc256 100644 --- a/packages/core/src/components/discord-unordered-list/DiscordUnorderedList.ts +++ b/packages/core/src/components/discord-unordered-list/DiscordUnorderedList.ts @@ -1,3 +1,4 @@ +import i18next from 'i18next'; import { css, html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { DiscordComponentsError } from '../../util.js'; @@ -43,9 +44,7 @@ export class DiscordUnorderedList extends LitElement { }); if (!allChildrenAreListItems) { - throw new DiscordComponentsError( - 'All direct children inside of a components must be one of , , or .' - ); + throw new DiscordComponentsError(i18next.t('discord-unordered-list.errors.invalid-children')); } } diff --git a/packages/core/src/components/discord-verified-author-tag/DiscordVerifiedAuthorTag.ts b/packages/core/src/components/discord-verified-author-tag/DiscordVerifiedAuthorTag.ts index 288136d77..9401b8c98 100644 --- a/packages/core/src/components/discord-verified-author-tag/DiscordVerifiedAuthorTag.ts +++ b/packages/core/src/components/discord-verified-author-tag/DiscordVerifiedAuthorTag.ts @@ -2,6 +2,7 @@ import { consume } from '@lit/context'; import { css, html, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { when } from 'lit/directives/when.js'; +import { translate } from '../../i18n/lit-integration.js'; import { messagesCompactMode } from '../discord-messages/DiscordMessages.js'; import VerifiedTick from '../svgs/VerifiedTick.js'; @@ -57,7 +58,7 @@ export class DiscordVerifiedAuthorTag extends LitElement { public accessor compactMode = false; protected override render() { - return html`${when(this.verified, () => VerifiedTick())}App`; + return html`${when(this.verified, () => VerifiedTick())}${translate('discord-verified-author-tag.app')}`; } } diff --git a/packages/core/src/i18n/init.ts b/packages/core/src/i18n/init.ts new file mode 100644 index 000000000..23eb8d6ec --- /dev/null +++ b/packages/core/src/i18n/init.ts @@ -0,0 +1,98 @@ +/* eslint-disable import-x/order, unicorn/no-useless-switch-case */ +import i18next from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import { initLitI18n } from './lit-integration.js'; +import { getI18nLanguage, type SupportedI18nLanguages } from './utils.js'; + +// Languages +import * as enUS from '../locales/en-US/index.js'; +import * as nl from '../locales/nl/index.js'; + +const namespaces = [ + // + 'discord-modal' +]; +const languages: SupportedI18nLanguages[] = [ + 'bg', + 'cs', + 'da', + 'de', + 'el', + 'en-GB', + 'en-US', + 'es-419', + 'es-ES', + 'fi', + 'fr', + 'hi', + 'hr', + 'hu', + 'id', + 'it', + 'ja', + 'ko', + 'lt', + 'nl', + 'no', + 'pl', + 'pt-BR', + 'ro', + 'ru', + 'sv-SE', + 'th', + 'tr', + 'uk', + 'vi', + 'zh-CN', + 'zh-TW' +]; + +void i18next + .use(LanguageDetector) + .use(initLitI18n) + .init({ + debug: true, + fallbackLng: { + 'es-419': ['es-ES', 'en-US'], + default: ['en-US'] + }, + initImmediate: true, + returnNull: false, + returnEmptyString: false, + returnObjects: true, + supportedLngs: languages, + preload: languages, + ns: namespaces, + lng: getI18nLanguage(), + interpolation: { + escapeValue: false, + skipOnVariables: false + } + }); + +for (const lng of languages) { + for (const namespace of namespaces) { + console.group('adding a namespaced language'); + console.log('adding: ', lng, namespace, Reflect.get(lngToObject(lng), kebabCaseToPascalCase(namespace))); + console.groupEnd(); + + i18next.addResourceBundle(lng, namespace, Reflect.get(lngToObject(lng), kebabCaseToPascalCase(namespace))); + } +} + +function kebabCaseToPascalCase(str: string): string { + return str + .split('-') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); +} + +function lngToObject(lng: SupportedI18nLanguages): Record { + switch (lng) { + case 'nl': + return nl; + case 'en-US': + default: + return enUS; + } +} diff --git a/packages/core/src/i18n/lit-integration.ts b/packages/core/src/i18n/lit-integration.ts new file mode 100644 index 000000000..fba2f77ca --- /dev/null +++ b/packages/core/src/i18n/lit-integration.ts @@ -0,0 +1,200 @@ +// License of original file: https://github.com/colscott/lit-i18n/blob/33126ba0b4364a7e8aa7ec2fc47f53d5ee64b86f/package.json#L40 +// Retrieved at https://github.com/colscott/lit-i18n/blob/33126ba0b4364a7e8aa7ec2fc47f53d5ee64b86f/src/lit-i18n.js + +import type { i18n as i18next, ThirdPartyModule, TOptions } from 'i18next'; +import { AsyncDirective, type Part, type PartInfo } from 'lit/async-directive.js'; +import { directive, PartType } from 'lit/directive.js'; +import { noChange } from 'lit/html.js'; + +let i18n: i18next | null = null; + +let i18nResolver: (i18n: i18next) => void = () => {}; + +const i18Provider = new Promise((resolve) => { + i18nResolver = resolve; +}); + +const initLitI18n: ThirdPartyModule = { + type: '3rdParty', + + /** + * initialize the i18next instance to use + * + * @param i18nextInstance - to use + */ + init(i18nextInstance: i18next) { + setI18n(i18nextInstance); + } +}; + +/** + * Sets the i18next instance to use for the translations. + * Favour using the plugin + * + * @param i18nextInstance - The instance to use + * @example + * ```js + * i18next.use(initLitI18n) + * ``` + */ +function setI18n(i18nextInstance: i18next) { + i18n = i18nextInstance; + i18nResolver(i18n); +} + +/** + * Used to keep track of Parts that need to be updated should the language change. + */ +const registry: Map = new Map(); + +/** + * Removes parts that are no longer connected. + * Called internally on a timer but can also be called manually. + */ +function registryCleanup() { + for (const [part] of registry.entries()) { + if (part.isConnected === false || isConnected(part) === false) { + registry.delete(part); + } + } +} + +/** + * lit-html does not seem to fire life cycle hook for part disconnected, we need to record and manage parts ourselves. + */ +globalThis.setInterval(registryCleanup, 10_000); + +let initialized = false; + +/** + * Iterates all registered translate directives re-evaluating the translations + */ +const updateAll = () => { + for (const [part, details] of registry.entries()) { + if (part.isConnected && isConnected(part)) { + const translation = translateAndInit(details.keys ?? [], details.options); + part.setValue(translation); + } + } +}; + +/** + * Lazily sets up i18next. In case this library is loaded before i18next has been loaded. + * This defers i18next setup until the first translation is requested. + */ +function translateAndInit(keys: string[] | string, opts?: TOptions): string { + if (!i18n) { + return ''; + } + + if (initialized === false) { + /** + * Handle language changes + */ + i18n.on('languageChanged', updateAll); + i18n.store.on('added', updateAll); + initialized = true; + } + + const translation = i18n.t(keys, opts); + + if (typeof translation === 'string') { + return translation; + } + + // returnObjects not supported https://www.i18next.com/translation-function/objects-and-arrays#objects + return ''; +} + +/** + * @param translateDirective - the directive to check + */ +function isConnected(translateDirective: Translate): boolean { + const part = translateDirective.part as Part; + + if (part.type === PartType.ATTRIBUTE) return part.element.isConnected; + if (part.type === PartType.CHILD) return part.parentNode ? part.parentNode.isConnected : false; + if (part.type === PartType.PROPERTY) return part.element.isConnected; + if (part.type === PartType.BOOLEAN_ATTRIBUTE) return part.element.isConnected; + if (part.type === PartType.EVENT) return part.element.isConnected; + if (part.type === PartType.ELEMENT) return part.element.isConnected; + + throw new Error('Unsupported Part'); +} + +class Translate extends AsyncDirective { + public value: string; + + public part: PartInfo; + + public constructor(part: PartInfo) { + super(part); + + this.value = ''; + this.part = part; + } + + /** + * @param keys - translation key + * @param options - i18next translation options + * @returns translated string + */ + public async render(keys: string[] | string, options?: TOptions): Promise { + await i18Provider?.then(() => { + this.setValue(this.translate(keys, options)); + }); + return noChange; + } + + /** + * @param keys - translation key + * @param options - i18next translation options + * @returns translated string + */ + public translate(keys: string[] | string, options?: TOptions | (() => TOptions)): string | symbol { + registry.set(this, { keys, options }); + + const translation = translateAndInit(keys, typeof options === 'function' ? options() : options); + + if (this.isConnected === false || translation === undefined || this.value === translation) { + return noChange; + } + + return translation; + } + + /** + * clean up the registry + */ + public override disconnected() { + registry.delete(this); + } +} + +/** + * The translate directive + * + * @example + * ```ts + * import { translate as t, i18next, html, render } from 'lit-i18n/src/lit-i18n.js'; + * i18next.init({...i18next config...}); + * class MyElement extends HTMLElement { + * connectedCallback() { + * this.person = { name: 'Fred', age: 23, male: true }; + * render(this.renderTemplate, this); + * } + * get renderTemplate() { + * return html` + * ${t('introduceself', { name: this.person.name })} + *
Div with translated title
+ *
+ * ${t('datamodel', { person: this.person })} + * + * `; + * } + * } + * ``` + */ +const translate = directive(Translate); + +export { setI18n, registry, registryCleanup, translate, initLitI18n }; diff --git a/packages/core/src/i18n/utils.ts b/packages/core/src/i18n/utils.ts new file mode 100644 index 000000000..5baf9ceae --- /dev/null +++ b/packages/core/src/i18n/utils.ts @@ -0,0 +1,43 @@ +export type SupportedI18nLanguages = + | 'bg' + | 'cs' + | 'da' + | 'de' + | 'el' + | 'en-GB' + | 'en-US' + | 'es-419' + | 'es-ES' + | 'fi' + | 'fr' + | 'hi' + | 'hr' + | 'hu' + | 'id' + | 'it' + | 'ja' + | 'ko' + | 'lt' + | 'nl' + | 'no' + | 'pl' + | 'pt-BR' + | 'ro' + | 'ru' + | 'sv-SE' + | 'th' + | 'tr' + | 'uk' + | 'vi' + | 'zh-CN' + | 'zh-TW'; + +let i18nLanguage: SupportedI18nLanguages = 'en-US'; + +export function getI18nLanguage() { + return i18nLanguage; +} + +export function setI18nLanguage(lng: SupportedI18nLanguages) { + i18nLanguage = lng; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e2c8c6393..b2d6e6992 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,5 @@ +import './i18n/init.js'; + import type { DiscordMessageOptions } from './types.js'; /* EXPORTS START */ @@ -51,6 +53,7 @@ export { DiscordVideoAttachment } from './components/discord-video-attachment/Di /* EXPORTS END */ export { getConfig, setConfig } from './config.js'; export { DiscordComponentsError } from './util.js'; +export { getI18nLanguage, setI18nLanguage, type SupportedI18nLanguages } from './i18n/utils.js'; export type * from './types.js'; declare global { diff --git a/packages/core/src/locales/en-US/discord-author-info.json b/packages/core/src/locales/en-US/discord-author-info.json new file mode 100644 index 000000000..1df9ff16d --- /dev/null +++ b/packages/core/src/locales/en-US/discord-author-info.json @@ -0,0 +1,4 @@ +{ + "server": "Server", + "op": "OP" +} diff --git a/packages/core/src/locales/en-US/discord-button.json b/packages/core/src/locales/en-US/discord-button.json new file mode 100644 index 000000000..f92101796 --- /dev/null +++ b/packages/core/src/locales/en-US/discord-button.json @@ -0,0 +1,7 @@ +{ + "errors": { + "type-error": "DiscordButton `type` prop must be a string.", + "range-error": "DiscordButton `type` prop must be one of: 'primary', 'secondary', 'success', 'destructive'", + "wrong-parent": "All components must be direct children of ." + } +} diff --git a/packages/core/src/locales/en-US/discord-command.json b/packages/core/src/locales/en-US/discord-command.json new file mode 100644 index 000000000..23d5b937d --- /dev/null +++ b/packages/core/src/locales/en-US/discord-command.json @@ -0,0 +1,3 @@ +{ + "used": "used" +} diff --git a/packages/core/src/locales/en-US/discord-embed-field.json b/packages/core/src/locales/en-US/discord-embed-field.json new file mode 100644 index 000000000..61128a2be --- /dev/null +++ b/packages/core/src/locales/en-US/discord-embed-field.json @@ -0,0 +1,5 @@ +{ + "errors": { + "range-error": "DiscordEmbedField `inlineIndex` prop must be one of: 1, 2, or 3" + } +} diff --git a/packages/core/src/locales/en-US/discord-header.json b/packages/core/src/locales/en-US/discord-header.json new file mode 100644 index 000000000..7238b2172 --- /dev/null +++ b/packages/core/src/locales/en-US/discord-header.json @@ -0,0 +1,5 @@ +{ + "errors": { + "range-error": "The level property must be a number between 1 and 3 (inclusive)" + } +} diff --git a/packages/core/src/locales/en-US/discord-input-text.json b/packages/core/src/locales/en-US/discord-input-text.json new file mode 100644 index 000000000..d4c4aa9d7 --- /dev/null +++ b/packages/core/src/locales/en-US/discord-input-text.json @@ -0,0 +1,13 @@ +{ + "errors": { + "label-required": "Label is required to input text", + "type-required": "Type is required to input text", + "type-must-be-string": "DiscordInputText `type` prop must be a string.", + "type-required-variant": "DiscordInputText `type` prop must be one of: 'short', 'paragraph'" + }, + "must-be-between-length": "- Must be between {{minLength}} and {{maxLength}} in length.", + "required-field": "Please fill out this field.", + "increase-length_one": "Please lengthen this text to {{minLength}} characters or more (you are currently using {{calculatedCharactersCount}} character)", + "increase-length_other": "Please lengthen this text to {{minLength}} characters or more (you are currently using {{calculatedCharactersCount}} characters)", + "required-minimum-length": "Must be {{minLength}} characters or more in length." +} diff --git a/packages/core/src/locales/en-US/discord-list-item.json b/packages/core/src/locales/en-US/discord-list-item.json new file mode 100644 index 000000000..0de076fbf --- /dev/null +++ b/packages/core/src/locales/en-US/discord-list-item.json @@ -0,0 +1,5 @@ +{ + "errors": { + "wrong-parent": "All components must be direct children of or ." + } +} diff --git a/packages/core/src/locales/en-US/discord-modal.json b/packages/core/src/locales/en-US/discord-modal.json new file mode 100644 index 000000000..952a1d713 --- /dev/null +++ b/packages/core/src/locales/en-US/discord-modal.json @@ -0,0 +1,4 @@ +{ + "cancel": "Cancel", + "submit": "Submit" +} diff --git a/packages/core/src/locales/en-US/discord-ordered-list.json b/packages/core/src/locales/en-US/discord-ordered-list.json new file mode 100644 index 000000000..498865d9c --- /dev/null +++ b/packages/core/src/locales/en-US/discord-ordered-list.json @@ -0,0 +1,5 @@ +{ + "errors": { + "invalid-children": "All direct children inside of a components must be one of , , or ." + } +} diff --git a/packages/core/src/locales/en-US/discord-reply.json b/packages/core/src/locales/en-US/discord-reply.json new file mode 100644 index 000000000..2c95776a6 --- /dev/null +++ b/packages/core/src/locales/en-US/discord-reply.json @@ -0,0 +1,7 @@ +{ + "original-message-deleted": "Original message was deleted", + "edited": "edited", + "server": "Server", + "op": "OP", + "app": "App" +} diff --git a/packages/core/src/locales/en-US/discord-string-select-menu-option.json b/packages/core/src/locales/en-US/discord-string-select-menu-option.json new file mode 100644 index 000000000..ab3072fc2 --- /dev/null +++ b/packages/core/src/locales/en-US/discord-string-select-menu-option.json @@ -0,0 +1,5 @@ +{ + "errors": { + "label-required": "The label of option is required" + } +} diff --git a/packages/core/src/locales/en-US/discord-system-message.json b/packages/core/src/locales/en-US/discord-system-message.json new file mode 100644 index 000000000..04683e80b --- /dev/null +++ b/packages/core/src/locales/en-US/discord-system-message.json @@ -0,0 +1,6 @@ +{ + "errors": { + "type-error": "DiscordSystemMessage `type` prop must be a string.", + "range-error": "DiscordSystemMessage `type` prop must be one of: 'join', 'leave', 'call', 'missed-call', 'boost', 'edit', 'thread', 'pin', 'alert', 'upgrade', 'error'" + } +} diff --git a/packages/core/src/locales/en-US/discord-thread-message.json b/packages/core/src/locales/en-US/discord-thread-message.json new file mode 100644 index 000000000..0bad4e3e6 --- /dev/null +++ b/packages/core/src/locales/en-US/discord-thread-message.json @@ -0,0 +1,4 @@ +{ + "app": "App", + "server": "Server" +} diff --git a/packages/core/src/locales/en-US/discord-unordered-list.json b/packages/core/src/locales/en-US/discord-unordered-list.json new file mode 100644 index 000000000..dd4f063c1 --- /dev/null +++ b/packages/core/src/locales/en-US/discord-unordered-list.json @@ -0,0 +1,5 @@ +{ + "errors": { + "invalid-children": "All direct children inside of a components must be one of , , or ." + } +} diff --git a/packages/core/src/locales/en-US/discord-verified-author-tag.json b/packages/core/src/locales/en-US/discord-verified-author-tag.json new file mode 100644 index 000000000..2959fe5fd --- /dev/null +++ b/packages/core/src/locales/en-US/discord-verified-author-tag.json @@ -0,0 +1,3 @@ +{ + "app": "App" +} diff --git a/packages/core/src/locales/en-US/index.ts b/packages/core/src/locales/en-US/index.ts new file mode 100644 index 000000000..255000fdd --- /dev/null +++ b/packages/core/src/locales/en-US/index.ts @@ -0,0 +1 @@ +export { default as DiscordModal } from './discord-modal.json'; diff --git a/packages/core/src/locales/nl/discord-modal.json b/packages/core/src/locales/nl/discord-modal.json new file mode 100644 index 000000000..ace14e7e9 --- /dev/null +++ b/packages/core/src/locales/nl/discord-modal.json @@ -0,0 +1,4 @@ +{ + "cancel": "Annuleren", + "submit": "Verzenden" +} diff --git a/packages/core/src/locales/nl/index.ts b/packages/core/src/locales/nl/index.ts new file mode 100644 index 000000000..255000fdd --- /dev/null +++ b/packages/core/src/locales/nl/index.ts @@ -0,0 +1 @@ +export { default as DiscordModal } from './discord-modal.json'; diff --git a/packages/core/src/tsconfig.json b/packages/core/src/tsconfig.json index 360c5541d..d896f7f9c 100644 --- a/packages/core/src/tsconfig.json +++ b/packages/core/src/tsconfig.json @@ -9,5 +9,5 @@ } ] }, - "include": ["."] + "include": [".", "./locales/**/*.json"] } diff --git a/packages/documentation/src/assets/README.md b/packages/documentation/src/assets/README.md index 0052306ee..0aacf5e26 100644 --- a/packages/documentation/src/assets/README.md +++ b/packages/documentation/src/assets/README.md @@ -95,6 +95,82 @@ do so by including the CSS below: } ``` +### Internationalization + +This package uses [i18next](https://www.i18next.com/) for internationalization. +We load +[i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) +plugin to attempt to detect the user's browser language, or you can set the +language yourself as seen at +[Setting the language manually](#setting-the-language-manually). + +#### Initialization + +i18next will be initialized by importing any component internally. +Alternatively, if you want to initialize it yourself (i.e. in your application +entrypoint) you can do so with the following code: + +```ts +import '@skyra/discord-components'; +``` + +or if you only want to load i18n and not any of the component side effects: + +```ts +import '@skyra/discord-components/i18n'; +``` + +#### Setting the language manually + +We expose the function `setI18nLanguage` which can be used to manually set the +language of i18next. You can use one of the following: + +```ts +import { setI18nLanguage } from '@skyra/discord-components'; +``` + +```ts +import { setI18nLanguage } from '@skyra/discord-components/i18n/utils'; +``` + +#### Supported languages + +The list of languages supported are matched to the list of languages the Discord +client supports. The list is as follows: + +- `bg`: Bulgarian +- `cs`: Czech +- `da`: Danish +- `de`: German +- `el`: Greek +- `en-GB`: English (British) +- `en-US`: English (American) +- `es-419`: Spanish (Latin America) +- `es-ES`: Spanish (Spain) +- `fi`: Finnish +- `fr`: French +- `hi`: Hindi +- `hr`: Croatian +- `hu`: Hungarian +- `id`: Indonesian +- `it`: Italian +- `ja`: Japanese +- `ko`: Korean +- `lt`: Lithuanian +- `nl`: Dutch +- `no`: Norwegian +- `pl`: Polish +- `pt-BR`: Portuguese (Brazil) +- `ro`: Romanian +- `ru`: Russian +- `sv-SE`: Swedish +- `th`: Thai +- `tr`: Turkish +- `uk`: Ukrainian +- `vi`: Vietnamese +- `zh-CN`: Chinese (Simplified) +- `zh-TW`: Chinese (Traditional) + ### Integrations #### Angular diff --git a/packages/react/README.md b/packages/react/README.md index dc6d733fa..c7e53ebb9 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -45,6 +45,11 @@ _React Bindings_ - [Usage](#usage) - [Important](#important) - [Using the Discord font](#using-the-discord-font) + - [Internationalization](#internationalization) + - [Initialization](#initialization) + - [Setting the language manually](#setting-the-language-manually) + - [Integrating with `react-i18next`](#integrating-with-react-i18next) + - [Supported languages](#supported-languages) - [Vite](#vite) - [Live Demo](#live-demo) - [Create React App](#create-react-app) @@ -165,6 +170,78 @@ do so by including the CSS below: } ``` +### Internationalization + +This package uses [i18next](https://www.i18next.com/) for internationalization. +We load i18next into Lit using [lit-i18n](https://github.com/colscott/lit-i18n) +and we also add the +[i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) +plugin to attempt to detect the user's browser language. + +#### Initialization + +i18next will be initialized by importing any component internally. +Alternatively, if you want to initialize it yourself (i.e. in your application +entrypoint) you can do so with the following code: + +```ts +import '@skyra/discord-components-react'; +``` + +#### Setting the language manually + +We expose the function `setI18nLanguage` which can be used to manually set the +language of i18next. You can use one of the following: + +```ts +import { setI18nLanguage } from '@skyra/discord-components-react'; +``` + +#### Integrating with `react-i18next` + +If you want to integrate this with +[`react-i18next`](https://github.com/i18next/react-i18next), you can simply +initialize i18next as shown above, then import either `useTranslation` or +`Trans` from `react-i18next` and use them as you would normally. + +#### Supported languages + +The list of languages supported are matched to the list of languages the Discord +client supports. The list is as follows: + +- `bg`: Bulgarian +- `cs`: Czech +- `da`: Danish +- `de`: German +- `el`: Greek +- `en-GB`: English (British) +- `en-US`: English (American) +- `es-419`: Spanish (Latin America) +- `es-ES`: Spanish (Spain) +- `fi`: Finnish +- `fr`: French +- `hi`: Hindi +- `hr`: Croatian +- `hu`: Hungarian +- `id`: Indonesian +- `it`: Italian +- `ja`: Japanese +- `ko`: Korean +- `lt`: Lithuanian +- `nl`: Dutch +- `no`: Norwegian +- `pl`: Polish +- `pt-BR`: Portuguese (Brazil) +- `ro`: Romanian +- `ru`: Russian +- `sv-SE`: Swedish +- `th`: Thai +- `tr`: Turkish +- `uk`: Ukrainian +- `vi`: Vietnamese +- `zh-CN`: Chinese (Simplified) +- `zh-TW`: Chinese (Traditional) + ### Vite #### Live Demo diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6a0a24b0e..f9824eade 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,5 @@ +import '@skyra/discord-components-core/i18n'; + import * as ReactComponents from '@skyra/discord-components-core'; import { createReactComponent } from './react-components/createComponent.js'; @@ -53,6 +55,7 @@ export const DiscordVideoAttachment = createReactComponent('discord-video-attach export type { Avatars, DiscordMessageOptions, DiscordTimestamp, Emoji, LightTheme, Profile } from '@skyra/discord-components-core'; export { getConfig, setConfig } from '@skyra/discord-components-core'; +export { getI18nLanguage, setI18nLanguage, type SupportedI18nLanguages } from '@skyra/discord-components-core/i18n/utils'; declare global { // eslint-disable-next-line no-var, vars-on-top diff --git a/scripts/update-exports-and-side-effects.mjs b/scripts/update-exports-and-side-effects.mjs index b9cefa134..fa79498e6 100644 --- a/scripts/update-exports-and-side-effects.mjs +++ b/scripts/update-exports-and-side-effects.mjs @@ -7,7 +7,9 @@ const coreComponentsDirectoryPath = new URL('../packages/core/src/components/', const coreComponentsDirectory = await readdir(coreComponentsDirectoryPath); const paths = { - '.': './dist/index.js' + '.': './dist/index.js', + './i18n': './dist/i18n/init.js', + './i18n/utils': './dist/i18n/utils.js' }; for (const item of coreComponentsDirectory) { diff --git a/yarn.lock b/yarn.lock index e563a8c3d..6ae42d4d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -445,6 +445,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.23.2": + version: 7.25.6 + resolution: "@babel/runtime@npm:7.25.6" + dependencies: + regenerator-runtime: "npm:^0.14.0" + checksum: 10/0c4134734deb20e1005ffb9165bf342e1074576621b246d8e5e41cc7cb315a885b7d98950fbf5c63619a2990a56ae82f444d35fe8c4691a0b70c2fe5673667dc + languageName: node + linkType: hard + "@babel/template@npm:^7.25.0": version: 7.25.0 resolution: "@babel/template@npm:7.25.0" @@ -2534,6 +2543,8 @@ __metadata: "@web/dev-server": "npm:^0.4.6" "@web/dev-server-esbuild": "npm:^1.0.2" concurrently: "npm:^8.2.2" + i18next: "npm:^23.14.0" + i18next-browser-languagedetector: "npm:^8.0.0" lit: "npm:^3.2.0" ts-lit-plugin: "npm:^2.0.2" typescript: "npm:^5.5.4" @@ -7465,6 +7476,24 @@ __metadata: languageName: node linkType: hard +"i18next-browser-languagedetector@npm:^8.0.0": + version: 8.0.0 + resolution: "i18next-browser-languagedetector@npm:8.0.0" + dependencies: + "@babel/runtime": "npm:^7.23.2" + checksum: 10/b2b4911b3c8dab0960cb5609053d12e61479123ab6397b4ea3d41a28630ebfb45d6ccc409baa164e86f35616cfad4d9aa56560041aeb9cd946b8495f1133ff97 + languageName: node + linkType: hard + +"i18next@npm:^23.14.0": + version: 23.14.0 + resolution: "i18next@npm:23.14.0" + dependencies: + "@babel/runtime": "npm:^7.23.2" + checksum: 10/661c1b22ae20bf75a616b3a804b96fd55bd04ed880853a05ef93912ed37f65b9546d8f8bbe55dfe2967a5b0f2ce110a7e9f4b7a6a3d90eb097d06c858d2b3e3f + languageName: node + linkType: hard + "iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3"