Skip to content

DEV: Add a Translation component. #33082

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 72 additions & 28 deletions app/assets/javascripts/discourse-i18n/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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<String, String>} 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);

Expand All @@ -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>|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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
* <Placeholder @name="username">
* <UserLink @user={{user}}>{{user.username}}</UserLink>
* </Placeholder>
* ```
*
* @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);
}

<template>
{{#if (eq @placeholder @name)}}
{{yield}}
<span {{didInsert this.markAsRendered}} />
{{/if}}
</template>
}
213 changes: 213 additions & 0 deletions app/assets/javascripts/discourse/app/components/translation.gjs
Original file line number Diff line number Diff line change
@@ -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}!"
* <Translation
* @scope="some.translation.key"
* @options={{hash shortdate=shortDate}}
* >
* <:placeholders as |Placeholder|>
* <Placeholder @name="username">
* <UserLink @user={{user}}>{{user.username}}</UserLink>
* </Placeholder>
* </:placeholders>
* </Translation>
* ```
*
* @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<String, String>}
*/
_placeholderKeys = new Map();

/**
* A map of placeholder keys to their corresponding DOM elements.
*
* @type {Map<String, HTMLElement>}
*/
_placeholderElements = new Map();

/**
* A map of placeholder keys to their appearance in the translation string.
*
* @type {Map<String, String>}
*/
_placeholderAppearance = new Map();

/**
* Tracks which placeholders have been rendered.
*
* @type {Array<String>}
*/
_renderedPlaceholders = [];

/**
* Processes the translation string and returns an array of text segments and
* placeholder elements that can be rendered in the template.
*
* @returns {Array<String|HTMLElement>} 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 <Translation> 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(", ")}`
);
}
}

<template>
{{#each this.textAndPlaceholders as |segment|}}
{{segment}}
{{/each}}

{{#each-in
this._placeholderElements
as |placeholderKey placeholderElement|
}}
{{#in-element placeholderElement}}
{{yield (this.placeholderElement placeholderKey) to="placeholders"}}
{{/in-element}}
{{/each-in}}
<span {{didInsert this.checkPlaceholders}} />
</template>
}
Loading
Loading