diff --git a/app/assets/javascripts/discourse-i18n/src/index.js b/app/assets/javascripts/discourse-i18n/src/index.js index fe5189cc2e2ed..659f0357549c4 100644 --- a/app/assets/javascripts/discourse-i18n/src/index.js +++ b/app/assets/javascripts/discourse-i18n/src/index.js @@ -71,26 +71,7 @@ export class I18n { options.needsPluralization = typeof options.count === "number"; options.ignoreMissing = !this.noFallbacks; - let translation = this.findTranslation(scope, options); - - if (!this.noFallbacks) { - if (!translation && this.fallbackLocale) { - options.locale = this.fallbackLocale; - translation = this.findTranslation(scope, options); - } - - options.ignoreMissing = false; - - if (!translation && this.currentLocale() !== this.defaultLocale) { - options.locale = this.defaultLocale; - translation = this.findTranslation(scope, options); - } - - if (!translation && this.currentLocale() !== "en") { - options.locale = "en"; - translation = this.findTranslation(scope, options); - } - } + const translation = this.findTranslationWithFallback(scope, options); try { return this.interpolate(translation, options, scope); @@ -227,16 +208,19 @@ export class I18n { interpolate(message, options, scope) { options = this.prepareOptions(options); - let matches = message.match(PLACEHOLDER); - let placeholder, value, name; + let value; - if (!matches) { - return message; + if (message === undefined) { + // Throw a generic error to be caught in _translate() + throw new Error(); } - for (let i = 0; (placeholder = matches[i]); i++) { - name = placeholder.replace(PLACEHOLDER, "$1"); + const placeholders = this.findPlaceholders(message); + if (placeholders.size === 0) { + return message; + } + for (const [name, placeholder] of placeholders) { if (typeof options[name] === "string") { // The dollar sign (`$`) is a special replace pattern, and `$&` inserts // the matched string. Thus dollars signs need to be escaped with the @@ -256,15 +240,41 @@ export class I18n { } let regex = new RegExp( - placeholder.replace(/\{/gm, "\\{").replace(/\}/gm, "\\}") + placeholder.replace(/\{/gm, "\\{").replace(/\}/gm, "\\}"), + "gi" ); - message = message.replace(regex, value); + message = message.replaceAll(regex, value); } return message; } + /** + * Extract the placeholders from the translated string before interpolation. + * + * @param {String} message The translated string. + * + * @returns {Map} A Map keyed by the placeholder name, with the value set to + * how the placeholder appears in the string (eg, "foo" => "%{foo}"). + */ + findPlaceholders(message) { + if (!message) { + return new Map(); + } + + const placeholders = message.match(PLACEHOLDER) || []; + + const placeholderMap = new Map(); + + placeholders.forEach((placeholder) => { + const name = placeholder.replace(PLACEHOLDER, "$1"); + placeholderMap.set(name, placeholder); + }); + + return placeholderMap; + } + findTranslation(scope, options) { let translation = this.lookup(scope, options); @@ -275,6 +285,40 @@ export class I18n { return translation; } + /** + * Given the current options (and if fallback is enabled), find the translation + * for the given scope. + * + * @param {String} scope The reference for the translatable string. + * @param {Object} options Custom options for this string. + * + * @returns {Array|String} The translated string, or array of strings for pluralizable translations. + */ + findTranslationWithFallback(scope, options) { + let translation = this.findTranslation(scope, options); + + if (!this.noFallbacks) { + if (!translation && this.fallbackLocale) { + options.locale = this.fallbackLocale; + translation = this.findTranslation(scope, options); + } + + options.ignoreMissing = false; + + if (!translation && this.currentLocale() !== this.defaultLocale) { + options.locale = this.defaultLocale; + translation = this.findTranslation(scope, options); + } + + if (!translation && this.currentLocale() !== "en") { + options.locale = "en"; + translation = this.findTranslation(scope, options); + } + } + + return translation; + } + findAndTranslateValidNode(keys, translation) { for (let key of keys) { if (this.isValidNode(translation, key)) { diff --git a/app/assets/javascripts/discourse/app/components/translation-placeholder.gjs b/app/assets/javascripts/discourse/app/components/translation-placeholder.gjs new file mode 100644 index 0000000000000..58e855f64f31c --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/translation-placeholder.gjs @@ -0,0 +1,41 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import { eq } from "truth-helpers"; + +/** + * Internally used by the Translation component to render placeholder + * content. This component conditionally renders its content only when the + * placeholder name matches the expected placeholder key. + * + * This component is only used through the Translation component's yielded + * Placeholder component, rather than directly. + * + * @component TranslationPlaceholder + * + * @template Usage example: + * ```gjs + * + * {{user.username}} + * + * ``` + * + * @param {String} name - The name of the placeholder this content should fill + */ +export default class TranslationPlaceholder extends Component { + /** + * Calls the parent component's markAsRendered function, to track that this + * placeholder has been rendered. + */ + @action + markAsRendered() { + this.args.markAsRendered(this.args.placeholder); + } + + +} diff --git a/app/assets/javascripts/discourse/app/components/translation.gjs b/app/assets/javascripts/discourse/app/components/translation.gjs new file mode 100644 index 0000000000000..8cee29c7e87e5 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/translation.gjs @@ -0,0 +1,213 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { getOwner } from "@ember/owner"; +import didInsert from "@ember/render-modifiers/modifiers/did-insert"; +import curryComponent from "ember-curry-component"; +import TranslationPlaceholder from "discourse/components/translation-placeholder"; +import uniqueId from "discourse/helpers/unique-id"; +import { isProduction } from "discourse/lib/environment"; +import I18n, { i18n, I18nMissingInterpolationArgument } from "discourse-i18n"; + +/** + * Provides the ability to interpolate both strings and components into translatable strings. + * This component allows for complex i18n scenarios where you need to embed interactive + * components within translated text. + * + * If you don't require this functionality, use the standard i18n() function. + * + * @component Translation + * + * @template Usage example: + * ```gjs + * // Translation key: "some.translation.key" = "Welcome, %{username}! The date is %{shortdate}!" + * + * <:placeholders as |Placeholder|> + * + * {{user.username}} + * + * + * + * ``` + * + * @param {String} scope - The i18n translation key to use + * @param {Object} [options] - Hash of options to pass to the i18n function for string interpolation + */ +export default class Translation extends Component { + /** + * A map of placeholder keys to their unique identifiers. + * + * @type {Map} + */ + _placeholderKeys = new Map(); + + /** + * A map of placeholder keys to their corresponding DOM elements. + * + * @type {Map} + */ + _placeholderElements = new Map(); + + /** + * A map of placeholder keys to their appearance in the translation string. + * + * @type {Map} + */ + _placeholderAppearance = new Map(); + + /** + * Tracks which placeholders have been rendered. + * + * @type {Array} + */ + _renderedPlaceholders = []; + + /** + * Processes the translation string and returns an array of text segments and + * placeholder elements that can be rendered in the template. + * + * @returns {Array} Array of text segments and placeholder elements + */ + get textAndPlaceholders() { + const optionsArg = this.args.options || {}; + + // Find all of the placeholders in the string we're looking at. + const message = I18n.findTranslationWithFallback(this.args.scope, { + ...optionsArg, + }); + this._placeholderAppearance = I18n.findPlaceholders(message); + + // We only need to keep the placeholders that aren't being handled by those passed in @options. + Object.keys(optionsArg).forEach((stringPlaceholder) => + this._placeholderAppearance.delete(stringPlaceholder) + ); + + this._placeholderAppearance.forEach((_, placeholderName) => { + this._placeholderKeys.set( + placeholderName, + `__PLACEHOLDER__${placeholderName}__${uniqueId()}__` + ); + this._placeholderElements.set( + placeholderName, + document.createElement("span") + ); + }); + + const text = i18n(this.args.scope, { + ...Object.fromEntries(this._placeholderKeys), + ...optionsArg, + }); + + if (text === I18n.missingTranslation(this.args.scope)) { + return [text]; + } + + // Bail early if there were no placeholders we need to handle. + if (this._placeholderAppearance.size === 0) { + if (isProduction()) { + return [text]; + } else { + throw new Error( + "The component shouldn't be used for strings that don't insert components. Use `i18n()` instead." + ); + } + } + + const parts = []; + let currentIndex = 0; + const placeholderRegex = /__PLACEHOLDER__([^_]+)__[^_]+__/g; + let match; + + while ((match = placeholderRegex.exec(text)) !== null) { + // Add text before placeholder if exists + if (match.index > currentIndex) { + parts.push(text.slice(currentIndex, match.index)); + } + + // Add the placeholder element, but only if the placeholder string we found matches + // the uniqueId we generated earlier for that placeholder. + if (this._placeholderKeys.get(match[1]) === match[0]) { + parts.push(this._placeholderElements.get(match[1])); + } + + currentIndex = match.index + match[0].length; + } + + // Add remaining text if any + if (currentIndex < text.length) { + parts.push(text.slice(currentIndex)); + } + + return parts; + } + + /** + * Creates a curried TranslationPlaceholder component for a specific placeholder. + * This allows the placeholder component to be passed to the template's named block + * with the placeholder name already bound. + * + * @param {String} placeholder - The name of the placeholder to create a component for + * @returns {Component} A curried TranslationPlaceholder component with the placeholder name bound + */ + @action + placeholderElement(placeholder) { + return curryComponent( + TranslationPlaceholder, + { placeholder, markAsRendered: this.markAsRendered }, + getOwner(this) + ); + } + + /** + * Marks a placeholder as having been rendered with content. + * Called by the TranslationPlaceholder component when it renders. + * + * @param {String} name - The name of the placeholder that has been rendered + */ + @action + markAsRendered(name) { + this._renderedPlaceholders.push(name); + } + + /** + * Checks for any placeholders that were expected but not provided in the template, then + * inserts a warning message where that placeholder was supposed to be. + */ + @action + checkPlaceholders() { + let missing = []; + for (const [name, element] of this._placeholderElements) { + if (!this._renderedPlaceholders.includes(name)) { + const message = `[missing ${this._placeholderAppearance.get( + name + )} placeholder]`; + element.innerText = message; + missing.push(message); + } + } + + if (!isProduction() && missing.length > 0) { + throw new I18nMissingInterpolationArgument( + `${this.args.scope}: ${missing.join(", ")}` + ); + } + } + + +} diff --git a/app/assets/javascripts/discourse/tests/integration/components/translation-test.gjs b/app/assets/javascripts/discourse/tests/integration/components/translation-test.gjs new file mode 100644 index 0000000000000..1bf595f27448e --- /dev/null +++ b/app/assets/javascripts/discourse/tests/integration/components/translation-test.gjs @@ -0,0 +1,166 @@ +import { hash } from "@ember/helper"; +import { render, resetOnerror, setupOnerror } from "@ember/test-helpers"; +import { module, test } from "qunit"; +import Translation from "discourse/components/translation"; +import UserLink from "discourse/components/user-link"; +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import I18n, { I18nMissingInterpolationArgument } from "discourse-i18n"; + +module("Integration | Component | Translation", function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this._locale = I18n.locale; + this._translations = I18n.translations; + + I18n.locale = "fr"; + + I18n.translations = { + fr: { + js: { + hello: "Bonjour, %{username}", + simple_text: "Simple text without placeholders", + with_options: "Hello %{name}, welcome to %{site}!", + multiple_placeholders: + "User %{user} commented on %{topic} at %{time}", + mixed_placeholders: + "Welcome %{username}! You have %{count} messages.", + user: { + profile_possessive: "Profil de %{username}", + }, + }, + }, + }; + }); + + hooks.afterEach(function () { + I18n.locale = this._locale; + I18n.translations = this._translations; + }); + + test("renders translation with component placeholder", async function (assert) { + await render( + + ); + + assert.dom().hasText("Bonjour, pento"); + assert.dom("a[data-user-card='pento']").exists(); + assert + .dom("a[data-user-card='pento']") + .hasAttribute("aria-label", "Profil de pento"); + }); + + test("throws an error on simple translation without placeholders", async function (assert) { + setupOnerror((error) => { + assert.strictEqual( + error.message, + "The component shouldn't be used for strings that don't insert components. Use `i18n()` instead." + ); + }); + + await render(); + + resetOnerror(); + }); + + test("renders translation with string options only", async function (assert) { + setupOnerror((error) => { + assert.strictEqual( + error.message, + "The component shouldn't be used for strings that don't insert components. Use `i18n()` instead." + ); + }); + + await render( + + ); + + resetOnerror(); + }); + + test("renders translation with both string options and component placeholders", async function (assert) { + await render( + + ); + + assert.dom().hasText("Welcome alice ! You have 5 messages."); + assert.dom("a[data-user-card='alice']").exists(); + }); + + test("renders translation with multiple component placeholders", async function (assert) { + await render( + + ); + + assert.dom().hasText("User bob commented on Important Topic at 2:30 PM"); + assert.dom("a[data-user-card='bob']").exists(); + assert.dom("strong").hasText("Important Topic"); + }); + + test("handles missing translation key gracefully", async function (assert) { + await render(); + + // When a translation key is missing, i18n returns the key itself + assert.dom().hasText("[fr.nonexistent_key]"); + }); + + test("handles placeholder not provided in template", async function (assert) { + setupOnerror((error) => { + assert.true(error instanceof I18nMissingInterpolationArgument); + assert.strictEqual( + error.message, + "hello: [missing %{username} placeholder]" + ); + }); + + // Translation has %{username} placeholder but no placeholder component is provided + await render( + + ); + + resetOnerror(); + + // Should render the placeholder string since no component was provided + assert.dom().includesText("Bonjour, [missing %{username} placeholder]"); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js b/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js index c46e43046dcf8..219bf28d6ff54 100644 --- a/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js +++ b/app/assets/javascripts/discourse/tests/unit/lib/i18n-test.js @@ -71,6 +71,8 @@ module("Unit | Utility | i18n", function (hooks) { }, dollar_sign: "Hi {{description}}", with_multiple_interpolate_arguments: "Hi %{username}, %{username2}", + with_repeated_interpolate_arguments: + "Hi, %{username}, your username is %{username}", }, }, ja: { @@ -333,6 +335,13 @@ module("Unit | Utility | i18n", function (hooks) { } }); + test("having an interpolation argument multiple times in a translation works correctly", function (assert) { + assert.strictEqual( + i18n("with_repeated_interpolate_arguments", { username: "pento" }), + "Hi, pento, your username is pento" + ); + }); + test("pluralizationNormalizedLocale", function (assert) { I18n.locale = "pt";