diff --git a/app/client/lib/localization.ts b/app/client/lib/localization.ts index fb81ad0c..4c03954a 100644 --- a/app/client/lib/localization.ts +++ b/app/client/lib/localization.ts @@ -1,4 +1,6 @@ import {getGristConfig} from 'app/common/urlUtils'; +import {DomContents} from 'grainjs'; +import {G} from 'grainjs/dist/cjs/lib/browserGlobals'; import i18next from 'i18next'; export async function setupLocale() { @@ -68,14 +70,48 @@ export async function setupLocale() { } /** - * Resolves the translation of the given key, using the given options. + * Resolves the translation of the given key using the given options. */ -export function t(key: string, args?: any): string { - if (!i18next.exists(key, args)) { +export function t(key: string, args?: any, instance = i18next): string { + if (!instance.exists(key, args)) { const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); reportError(error); } - return i18next.t(key, args); + return instance.t(key, args); +} + + +/** + * Resolves the translation of the given key and substitutes. Supports dom elements interpolation. + */ + export function domT(key: string, args?: any, instance = i18next): DomContents { + if (!instance.exists(key)) { + const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); + reportError(error); + } + // If there are any DomElements in args, handle it with missingInterpolationHandler. + const domElements = Object.entries(args).filter(([_, value]) => isLikeDomContents(value)); + if (!args || !domElements.length) { + return t(key, args); + } else { + // Make a copy of the arguments, and remove any dom elements from it. It will instruct + // i18next library to use `missingInterpolationHandler` handler. + const copy = {...args}; + domElements.forEach(([prop]) => delete copy[prop]); + + // Passing `missingInterpolationHandler` will allow as to resolve all missing keys + // and replace them with a marker. + const result: string = instance.t(key, {...copy, missingInterpolationHandler}); + + // Now replace all markers with dom elements passed as arguments. + const parts = result.split(new RegExp(`(${DOM_MARKER}\\w+)`)); + for (let i = 1; i < parts.length; i += 2) { // Every second element is our dom element. + const propName = parts[i].replace(DOM_MARKER, ""); + const domElement = args[propName] ?? `{{${propName}}}`; // If the prop is not there, simulate default behavior. + parts[i] = domElement; + } + return parts; + } } /** @@ -84,3 +120,19 @@ export function t(key: string, args?: any): string { export function hasTranslation(key: string) { return i18next.exists(key); } + +const DOM_MARKER = '__domKey_'; +function missingInterpolationHandler(key: string, value: any) { + return `${DOM_MARKER}${value[1]}`; +} + +/** + * Very naive detection if an element has DomContents type. + */ +function isLikeDomContents(value: any): boolean { + // As null and undefined are valid DomContents values, we don't treat them as such. + if (value === null || value === undefined) { return false; } + return value instanceof G.Node || // Node + (Array.isArray(value) && isLikeDomContents(value[0])) || // DomComputed + typeof value === 'function'; // DomMethod +} diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index 02f4d812..2e27c2ed 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -1,4 +1,4 @@ -import {t} from 'app/client/lib/localization'; +import {domT, t} from 'app/client/lib/localization'; import {getLoginOrSignupUrl, urlState} from 'app/client/models/gristUrlState'; import {HomeModel} from 'app/client/models/HomeModel'; import {productPill} from 'app/client/ui/AppHeader'; @@ -103,7 +103,7 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) { css.docListHeader(`Welcome to Grist, ${user.name}!`, testId('welcome-title')), cssIntroLine('Get started by creating your first Grist document.'), (shouldHideUiElement('helpCenter') ? null : - cssIntroLine('Visit our ', helpCenterLink(), ' to learn more.', + cssIntroLine(domT('HomeIntro.VisitHelpCenter', { link: helpCenterLink() }), testId('welcome-text')) ), makeCreateButtons(homeModel), @@ -115,8 +115,8 @@ function makeAnonIntro(homeModel: HomeModel) { return [ css.docListHeader(translate('Welcome'), testId('welcome-title')), cssIntroLine('Get started by exploring templates, or creating your first Grist document.'), - cssIntroLine(signUp, ' to save your work.', - (shouldHideUiElement('helpCenter') ? null : [' Visit our ', helpCenterLink(), ' to learn more.']), + cssIntroLine(signUp, ' to save your work. ', + (shouldHideUiElement('helpCenter') ? null : domT('HomeIntro.VisitHelpCenter', { link: helpCenterLink() })), testId('welcome-text')), makeCreateButtons(homeModel), ]; diff --git a/static/locales/en.client.json b/static/locales/en.client.json index 09041c0e..382f828f 100644 --- a/static/locales/en.client.json +++ b/static/locales/en.client.json @@ -12,6 +12,7 @@ }, "HomeIntro": { "Welcome": "Welcome to Grist!", - "SignUp": "Sign up" + "SignUp": "Sign up", + "VisitHelpCenter": "Visit our {{link}} to learn more." } } diff --git a/test/client/lib/localization.ts b/test/client/lib/localization.ts new file mode 100644 index 00000000..ccd1fa7e --- /dev/null +++ b/test/client/lib/localization.ts @@ -0,0 +1,56 @@ +import {domT, t} from 'app/client/lib/localization'; +import {assert} from 'chai'; +import i18next, {i18n} from 'i18next'; +import {dom} from "grainjs"; +import {popGlobals, pushGlobals, G} from 'grainjs/dist/cjs/lib/browserGlobals'; +import {JSDOM} from 'jsdom'; + +describe('localization', function() { + beforeEach(function() { + // These grainjs browserGlobals are needed for using dom() in tests. + const jsdomDoc = new JSDOM(""); + pushGlobals(jsdomDoc.window); + }); + + afterEach(function() { + popGlobals(); + }); + + let instance: i18n; + before(() => { + instance = i18next.createInstance(); + instance.init({ + lng: 'en', + resources: { + en: { + translation: { + 'Text': 'TranslatedText', + 'Argument': 'Translated {{arg1}} {{arg2}}.', + } + } + } + }); + }); + + it('supports basic operation', function() { + assert.equal(t('Text', null, instance), 'TranslatedText'); + assert.equal(t('Argument', {arg1: '1', arg2: '2'}, instance), 'Translated 1 2.'); + }); + + it('supports dom content interpolation', function() { + const result = domT('Argument', { + arg1: dom('span', 'First'), + arg2: dom.domComputed("test", (value) => dom('span', value)) + }, instance) as any; + assert.isTrue(Array.isArray(result)); + assert.equal(result.length, 5); + assert.equal(result[0], 'Translated '); + assert.equal(result[1]?.tagName, 'SPAN'); + assert.equal(result[1]?.textContent, 'First'); + assert.equal(result[2], ' '); + // Element 3 is the domComputed [Comment, Comment, function()] + assert.isTrue(Array.isArray(result[3])); + assert.isTrue(result[3][0] instanceof G.Node); + assert.equal(result[4], '.'); + }); +});