diff --git a/public/script.js b/public/script.js index 4534e12d6..11c8279ae 100644 --- a/public/script.js +++ b/public/script.js @@ -209,7 +209,7 @@ import { instruct_presets, selectContextPreset, } from './scripts/instruct-mode.js'; -import { initLocales } from './scripts/i18n.js'; +import { initLocales, t, translate } from './scripts/i18n.js'; import { getFriendlyTokenizerName, getTokenCount, getTokenCountAsync, getTokenizerModel, initTokenizers, saveTokenCache } from './scripts/tokenizers.js'; import { user_avatar, @@ -7826,6 +7826,8 @@ window['SillyTavern'].getContext = function () { messageFormatting: messageFormatting, shouldSendOnEnter: shouldSendOnEnter, isMobile: isMobile, + t: t, + translate: translate, tags: tags, tagMap: tag_map, menuType: menu_type, diff --git a/public/scripts/i18n.js b/public/scripts/i18n.js index df86a3c26..d1ed2f3ea 100644 --- a/public/scripts/i18n.js +++ b/public/scripts/i18n.js @@ -9,6 +9,71 @@ const langs = await fetch('/locales/lang.json').then(response => response.json() // eslint-disable-next-line prefer-const var localeData = await getLocaleData(localeFile); +/** + * An observer that will check if any new i18n elements are added to the document + * @type {MutationObserver} + */ +const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + mutation.addedNodes.forEach(node => { + if (node.nodeType === Node.ELEMENT_NODE && node instanceof Element) { + if (node.hasAttribute('data-i18n')) { + translateElement(node); + } + node.querySelectorAll('[data-i18n]').forEach(element => { + translateElement(element); + }); + } + }); + if (mutation.attributeName === 'data-i18n' && mutation.target instanceof Element) { + translateElement(mutation.target); + } + }); +}); + +/** + * Translates a template string with named arguments + * + * Uses the template literal with all values replaced by index placeholder for translation key. + * + * @example + * ```js + * toastr.warn(t`Tag ${tagName} not found.`); + * ``` + * Should be translated in the translation files as: + * ``` + * Tag ${0} not found. -> Tag ${0} nicht gefunden. + * ``` + * + * @param {TemplateStringsArray} strings - Template strings array + * @param {...any} values - Values for placeholders in the template string + * @returns {string} Translated and formatted string + */ +export function t(strings, ...values) { + let str = strings.reduce((result, string, i) => result + string + (values[i] !== undefined ? `\${${i}}` : ''), ''); + let translatedStr = translate(str); + + // Replace indexed placeholders with actual values + return translatedStr.replace(/\$\{(\d+)\}/g, (match, index) => values[index]); +} + +/** + * Translates a given key or text + * + * If the translation is based on a key, that one is used to find a possible translation in the translation file. + * The original text still has to be provided, as that is the default value being returned if no translation is found. + * + * For in-code text translation on a format string, using the template literal `t` is preferred. + * + * @param {string} text - The text to translate + * @param {string?} key - The key to use for translation. If not provided, text is used as the key. + * @returns {string} - The translated text + */ +export function translate(text, key = null) { + const translationKey = key || text; + return localeData?.[translationKey] || text; +} + /** * Fetches the locale data for the given language. * @param {string} language Language code @@ -40,6 +105,29 @@ function findLang(language) { return supportedLang; } +/** + * Translates a given element based on its data-i18n attribute. + * @param {Element} element The element to translate + */ +function translateElement(element) { + const keys = element.getAttribute('data-i18n').split(';'); // Multi-key entries are ; delimited + for (const key of keys) { + const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key + if (attributeMatch) { // attribute-tagged key + const localizedValue = localeData?.[attributeMatch[2]]; + if (localizedValue || localizedValue === '') { + element.setAttribute(attributeMatch[1], localizedValue); + } + } else { // No attribute tag, treat as 'text' + const localizedValue = localeData?.[key]; + if (localizedValue || localizedValue === '') { + element.textContent = localizedValue; + } + } + } +} + + async function getMissingTranslations() { const missingData = []; @@ -103,22 +191,7 @@ export function applyLocale(root = document) { //find all the elements with `data-i18n` attribute $root.find('[data-i18n]').each(function () { - //read the translation from the language data - const keys = $(this).data('i18n').split(';'); // Multi-key entries are ; delimited - for (const key of keys) { - const attributeMatch = key.match(/\[(\S+)\](.+)/); // [attribute]key - if (attributeMatch) { // attribute-tagged key - const localizedValue = localeData?.[attributeMatch[2]]; - if (localizedValue || localizedValue == '') { - $(this).attr(attributeMatch[1], localizedValue); - } - } else { // No attribute tag, treat as 'text' - const localizedValue = localeData?.[key]; - if (localizedValue || localizedValue == '') { - $(this).text(localizedValue); - } - } - } + translateElement(this); }); if (root !== document) { @@ -126,7 +199,6 @@ export function applyLocale(root = document) { } } - function addLanguagesToDropdown() { const uiLanguageSelects = $('#ui_language_select, #onboarding_ui_language_select'); for (const langObj of langs) { // Set the value to the language code @@ -159,6 +231,13 @@ export function initLocales() { location.reload(); }); + observer.observe(document, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-i18n'], + }); + registerDebugFunction('getMissingTranslations', 'Get missing translations', 'Detects missing localization data in the current locale and dumps the data into the browser console. If the current locale is English, searches all other locales.', getMissingTranslations); registerDebugFunction('applyLocale', 'Apply locale', 'Reapplies the currently selected locale to the page.', applyLocale); }