From cc2a438fe58e073aaaf476263a1407cb771a4e9d Mon Sep 17 00:00:00 2001 From: Arnaud Peich Date: Thu, 13 Oct 2022 11:23:55 +0200 Subject: [PATCH 1/7] Split client and server translations, organize by filename --- app/client/lib/localization.ts | 10 +++--- app/client/ui/AddNewButton.ts | 2 +- app/client/ui/DocMenu.ts | 8 ++--- app/client/ui/HomeIntro.ts | 4 +-- app/server/lib/sendAppPage.ts | 2 +- app/server/localization.ts | 8 ++--- documentation/translations.md | 57 ++++++++++++++++++++-------------- static/locales/en.client.json | 17 ++++++++++ static/locales/en.core.json | 12 ------- static/locales/en.server.json | 5 +++ 10 files changed, 72 insertions(+), 53 deletions(-) create mode 100644 static/locales/en.client.json delete mode 100644 static/locales/en.core.json create mode 100644 static/locales/en.server.json diff --git a/app/client/lib/localization.ts b/app/client/lib/localization.ts index cae955a5..102184b6 100644 --- a/app/client/lib/localization.ts +++ b/app/client/lib/localization.ts @@ -15,7 +15,7 @@ export async function setupLocale() { } } - const ns = getGristConfig().namespaces ?? ['core']; + const ns = getGristConfig().namespaces ?? ['client']; // Initialize localization plugin try { // We don't await this promise, as it is resolved synchronously due to initImmediate: false. @@ -28,13 +28,13 @@ export async function setupLocale() { initImmediate: false, // Read language from navigator object. lng, - // By default we use core namespace. - defaultNS: 'core', + // By default we use client namespace. + defaultNS: 'client', // Read namespaces that are supported by the server. // TODO: this can be converted to a dynamic list of namespaces, for async components. // for now just import all what server offers. - // We can fallback to core namespace for any addons. - fallbackNS: 'core', + // We can fallback to client namespace for any addons. + fallbackNS: 'client', ns, supportedLngs }).catch((err: any) => { diff --git a/app/client/ui/AddNewButton.ts b/app/client/ui/AddNewButton.ts index 6a7a6ede..2e3d33c9 100644 --- a/app/client/ui/AddNewButton.ts +++ b/app/client/ui/AddNewButton.ts @@ -8,7 +8,7 @@ export function addNewButton(isOpen: Observable | boolean = true, ...ar cssAddNewButton.cls('-open', isOpen), // Setting spacing as flex items allows them to shrink faster when there isn't enough space. cssLeftMargin(), - cssAddText(t('AddNew')), + cssAddText(t('AddNewButton.AddNew')), dom('div', {style: 'flex: 1 1 16px'}), cssPlusButton(cssPlusIcon('Plus')), dom('div', {style: 'flex: 0 1 16px'}), diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index c5dc1177..2b2da41e 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -105,10 +105,10 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { null : css.docListHeader( ( - page === 'all' ? t('AllDocuments') : + page === 'all' ? t('DocMenu.AllDocuments') : page === 'templates' ? dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) => - hasFeaturedTemplates ? t('MoreExamplesAndTemplates') : t('ExamplesAndTemplates') + hasFeaturedTemplates ? t('DocMenu.MoreExamplesAndTemplates') : t('DocMenu.ExamplesAndTemplates') ) : page === 'trash' ? 'Trash' : workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)] @@ -268,7 +268,7 @@ function buildOtherSites(home: HomeModel) { return css.otherSitesBlock( dom.autoDispose(hideOtherSitesObs), css.otherSitesHeader( - t('OtherSites'), + t('DocMenu.OtherSites'), dom.domComputed(hideOtherSitesObs, (collapsed) => collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse') ), @@ -280,7 +280,7 @@ function buildOtherSites(home: HomeModel) { const siteName = home.app.currentOrgName; return [ dom('div', - t('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), + t('DocMenu.OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), testId('other-sites-message') ), css.otherSitesButtons( diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index ed2a6fbf..b47be7f9 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -110,9 +110,9 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) { } function makeAnonIntro(homeModel: HomeModel) { - const signUp = cssLink({href: getLoginOrSignupUrl()}, t('SignUp')); + const signUp = cssLink({href: getLoginOrSignupUrl()}, t('HomeIntro.SignUp')); return [ - css.docListHeader(t('Welcome'), testId('welcome-title')), + css.docListHeader(t('HomeIntro.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.']), diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index e961b9e8..892297cb 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -154,7 +154,7 @@ function configuredPageTitleSuffix() { */ function getPageTitle(req: express.Request, config: GristLoadConfig): string { const maybeDoc = getDocFromConfig(config); - if (!maybeDoc) { return req.t('Loading') + "..."; } + if (!maybeDoc) { return req.t('sendAppPage.Loading') + "..."; } return handlebars.Utils.escapeExpression(maybeDoc.name); } diff --git a/app/server/localization.ts b/app/server/localization.ts index 5e06af21..ba5b1b2a 100644 --- a/app/server/localization.ts +++ b/app/server/localization.ts @@ -26,8 +26,8 @@ export function setupLocale(appRoot: string): i18n { supportedNamespaces.add(namespace); return lang; }).filter((lang) => lang)); - if (!supportedLngs.has('en') || !supportedNamespaces.has('core')) { - throw new Error("Missing core English language file"); + if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) { + throw new Error("Missing server English language file"); } // Initialize localization filesystem plugin that will read the locale files from the localeDir. instance.use(i18fsBackend); @@ -40,7 +40,7 @@ export function setupLocale(appRoot: string): i18n { initImmediate: false, preload: [...supportedLngs], supportedLngs: [...supportedLngs], - defaultNS: 'core', + defaultNS: 'server', ns: [...supportedNamespaces], fallbackLng: 'en', backend: { @@ -71,5 +71,5 @@ export function readLoadedNamespaces(instance?: i18n): readonly string[] { if (Array.isArray(instance?.options.ns)) { return instance.options.ns; } - return instance?.options.ns ? [instance.options.ns as string] : ['core']; + return instance?.options.ns ? [instance.options.ns as string] : ['server']; } diff --git a/documentation/translations.md b/documentation/translations.md index f13e666f..36bc2898 100644 --- a/documentation/translations.md +++ b/documentation/translations.md @@ -20,37 +20,46 @@ default language _en_ (https://www.i18next.com/principles/translation-resolution All language variants (e.g., _fr-FR_, _pl-PL_, _en-UK_) are supported if Grist can find a main language resource file. For example, to support a _fr-FR_ language code, Grist expects to have at -least _fr.core.json_ file. The main language file will be used as a default fallback for all French +least _fr.client.json_ file. The main language file will be used as a default fallback for all French language codes like _fr-FR_ or _fr-CA_, in case there is no resource file for a specif variant (like -`fr-CA.core.json`) or some keys are missing from the variant file. +`fr-CA.client.json`) or some keys are missing from the variant file. -Here is an example of a language resource file `en.core.json` currently used by Grist: +Here is an example of a language resource file `en.client.json` currently used by Grist: ```json { - "Welcome": "Welcome to Grist!", - "Loading": "Loading", - "AddNew": "Add New", - "OtherSites": "Other Sites", - "OtherSitesWelcome": "Your are on {{siteName}}. You also have access to the following sites:", - "OtherSitesWelcome_personal": "Your are on your personal site. You also have access to the following sites:", - "AllDocuments": "All Documents", - "ExamplesAndTemplates": "Examples and Templates", - "MoreExamplesAndTemplates": "More Examples and Templates" + "AddNewButton": { + "AddNew": "Add New" + }, + "DocMenu": { + "OtherSites": "Other Sites", + "OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:", + "OtherSitesWelcome_personal": "You are on your personal site. You also have access to the following sites:", + "AllDocuments": "All Documents", + "ExamplesAndTemplates": "Examples and Templates", + "MoreExamplesAndTemplates": "More Examples and Templates" + }, + "HomeIntro": { + "Welcome": "Welcome to Grist!", + "SignUp": "Sign up" + } } ``` It maps a key to a translated message. It also has an example of interpolation and context features -in the `OtherSitesWelcome` resource key. More information about how to use those features can be +in the `DocMenu.OtherSitesWelcome` resource key. More information about how to use those features can be found at https://www.i18next.com/translation-function/interpolation and https://www.i18next.com/translation-function/context. -Both client and server code (node.js) use the same resource files. A resource file name format -follows a pattern: [language code].[product].json (i.e. `pl-Pl.core.json`, `en-US.core.json`, -`en.core.json`). Grist can be packaged as several different products, and each product can have its +Client and server code (node.js) use separate resource files. A resource file name format +follows a pattern: [language code].[product].json (e.g. `pl-Pl.client.json`, `en-US.client.json`, +`en.client.json`). Grist can be packaged as several different products, and each product can have its own translation files that are added to the core. Products are supported by leveraging `i18next` feature called `namespaces` https://www.i18next.com/principles/namespaces. +For now we use only two products called `client` and `server`. +Each of them is then organized by filename, in order to avoid conflicts. + ## Translation instruction ### Client @@ -58,7 +67,7 @@ feature called `namespaces` https://www.i18next.com/principles/namespaces. The entry point for all translations is a function exported from 'app/client/lib/localization'. ```ts -import { t } from 'app/client/lib/localization'; +import {t} from 'app/client/lib/localization'; ``` It is a wrapper around `i18next` exported method with the same interface @@ -71,7 +80,7 @@ _app/client/ui.DocMenu.ts_ ```ts css.otherSitesHeader( - t('OtherSites'), + t('DocMenu.OtherSites'), ..... ), dom.maybe((use) => !use(hideOtherSitesObs), () => { @@ -79,7 +88,7 @@ _app/client/ui.DocMenu.ts_ const siteName = home.app.currentOrgName; return [ dom('div', - t('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), + t('DocMenu.OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), testId('other-sites-message') ``` @@ -87,9 +96,9 @@ _app/client/ui/HomeIntro.ts_ ```ts function makeAnonIntro(homeModel: HomeModel) { - const signUp = cssLink({href: getLoginOrSignupUrl()}, t('SignUp')); + const signUp = cssLink({href: getLoginOrSignupUrl()}, t('HomeIntro.SignUp')); return [ - css.docListHeader(t('Welcome'), testId('welcome-title')), + css.docListHeader(t('HomeIntro.Welcome'), testId('welcome-title')), ``` Some things are not supported at this moment and will need to be addressed in future development @@ -124,7 +133,7 @@ _app/server/lib/sendAppPage.ts_ function getPageTitle(req: express.Request, config: GristLoadConfig): string { const maybeDoc = getDocFromConfig(config); if (!maybeDoc) { - return req.t('Loading') + '...'; + return req.t('sendAppPage.Loading') + '...'; } return handlebars.Utils.escapeExpression(maybeDoc.name); @@ -133,8 +142,8 @@ function getPageTitle(req: express.Request, config: GristLoadConfig): string { ### Next steps -- Annotate all client code and create all resource files in `en.core.json` file. Almost all static - text is ready for translation. +- Annotate all client code and create all resource files in `en.client.json` and `en.server.json` files. + Almost all static text is ready for translation. - Store language settings with the user profile and allow a user to change it on the Account Page. Consider also adding a cookie-based solution that custom widgets can use, or extend the **WidgetFrame** component so that it can pass current user language to the hosted widget page. diff --git a/static/locales/en.client.json b/static/locales/en.client.json new file mode 100644 index 00000000..09041c0e --- /dev/null +++ b/static/locales/en.client.json @@ -0,0 +1,17 @@ +{ + "AddNewButton": { + "AddNew": "Add New" + }, + "DocMenu": { + "OtherSites": "Other Sites", + "OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:", + "OtherSitesWelcome_personal": "You are on your personal site. You also have access to the following sites:", + "AllDocuments": "All Documents", + "ExamplesAndTemplates": "Examples and Templates", + "MoreExamplesAndTemplates": "More Examples and Templates" + }, + "HomeIntro": { + "Welcome": "Welcome to Grist!", + "SignUp": "Sign up" + } +} diff --git a/static/locales/en.core.json b/static/locales/en.core.json deleted file mode 100644 index b9818c72..00000000 --- a/static/locales/en.core.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "Welcome": "Welcome to Grist!", - "SignUp": "Sign up", - "Loading": "Loading", - "AddNew": "Add New", - "OtherSites": "Other Sites", - "OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:", - "OtherSitesWelcome_personal": "You are on your personal site. You also have access to the following sites:", - "AllDocuments": "All Documents", - "ExamplesAndTemplates": "Examples and Templates", - "MoreExamplesAndTemplates": "More Examples and Templates" -} diff --git a/static/locales/en.server.json b/static/locales/en.server.json new file mode 100644 index 00000000..ce247b17 --- /dev/null +++ b/static/locales/en.server.json @@ -0,0 +1,5 @@ +{ + "sendAppPage": { + "Loading": "Loading" + } +} From 390d8406a60c7cf2ae2a09030a74f1e360920256 Mon Sep 17 00:00:00 2001 From: Arnaud Peich Date: Thu, 13 Oct 2022 12:05:19 +0200 Subject: [PATCH 2/7] Fix tests --- test/nbrowser/Localization.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/test/nbrowser/Localization.ts b/test/nbrowser/Localization.ts index b1179186..c975e280 100644 --- a/test/nbrowser/Localization.ts +++ b/test/nbrowser/Localization.ts @@ -23,8 +23,8 @@ describe("Localization", function() { // Grist config should contain the list of supported languages; const gristConfig: any = await driver.executeScript("return window.gristConfig"); - // core and en is required. - assert.isTrue(gristConfig.namespaces.includes("core")); + // client and en is required. + assert.isTrue(gristConfig.namespaces.includes("client")); assert.isTrue(gristConfig.supportedLngs.includes("en")); }); @@ -81,13 +81,13 @@ describe("Localization", function() { function present(response: string, ...langs: string[]) { for (const lang of langs) { - assert.include(response, `href="locales/${lang}.core.json"`); + assert.include(response, `href="locales/${lang}.client.json"`); } } function notPresent(response: string, ...langs: string[]) { for (const lang of langs) { - assert.notInclude(response, `href="locales/${lang}.core.json"`); + assert.notInclude(response, `href="locales/${lang}.client.json"`); } } @@ -109,7 +109,7 @@ describe("Localization", function() { }); it("loads correct languages from file system", async function() { - modifyByCode(tempLocale, "en", {Welcome: 'TestMessage'}); + modifyByCode(tempLocale, "en", {HomeIntro: {Welcome: 'TestMessage'}}); await driver.navigate().refresh(); assert.equal(await driver.findWait('.test-welcome-title', 3000).getText(), 'TestMessage'); const gristConfig: any = await driver.executeScript("return window.gristConfig"); @@ -163,8 +163,8 @@ describe("Localization", function() { } function modifyByCode(localeDir: string, code: string, obj: any) { - // Read current core localization file. - const filePath = path.join(localeDir, `${code}.core.json`); + // Read current client localization file. + const filePath = path.join(localeDir, `${code}.client.json`); const resources = JSON.parse(fs.readFileSync(filePath).toString()); const newResource = Object.assign(resources, obj); fs.writeFileSync(filePath, JSON.stringify(newResource)); From a44989e4dd640a527b247043c536563d7b995aa4 Mon Sep 17 00:00:00 2001 From: Arnaud Peich Date: Thu, 13 Oct 2022 12:31:26 +0200 Subject: [PATCH 3/7] Introduce translate helpers --- app/client/ui/AddNewButton.ts | 4 +++- app/client/ui/DocMenu.ts | 10 ++++++---- app/client/ui/HomeIntro.ts | 5 +++-- app/server/lib/sendAppPage.ts | 4 +++- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/client/ui/AddNewButton.ts b/app/client/ui/AddNewButton.ts index 2e3d33c9..7e792c6a 100644 --- a/app/client/ui/AddNewButton.ts +++ b/app/client/ui/AddNewButton.ts @@ -3,12 +3,14 @@ import {icon} from 'app/client/ui2018/icons'; import {dom, DomElementArg, Observable, styled} from "grainjs"; import {t} from 'app/client/lib/localization'; +const translate = (x: string, args?: any): string => t(`AddNewButton.${x}`, args); + export function addNewButton(isOpen: Observable | boolean = true, ...args: DomElementArg[]) { return cssAddNewButton( cssAddNewButton.cls('-open', isOpen), // Setting spacing as flex items allows them to shrink faster when there isn't enough space. cssLeftMargin(), - cssAddText(t('AddNewButton.AddNew')), + cssAddText(translate('AddNew')), dom('div', {style: 'flex: 1 1 16px'}), cssPlusButton(cssPlusIcon('Plus')), dom('div', {style: 'flex: 0 1 16px'}), diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index 2b2da41e..28ec71ea 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -34,6 +34,8 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {bigBasicButton} from 'app/client/ui2018/buttons'; import sortBy = require('lodash/sortBy'); +const translate = (x: string, args?: any): string => t(`DocMenu.${x}`, args); + const testId = makeTestId('test-dm-'); /** @@ -105,10 +107,10 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) { null : css.docListHeader( ( - page === 'all' ? t('DocMenu.AllDocuments') : + page === 'all' ? translate('AllDocuments') : page === 'templates' ? dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) => - hasFeaturedTemplates ? t('DocMenu.MoreExamplesAndTemplates') : t('DocMenu.ExamplesAndTemplates') + hasFeaturedTemplates ? translate('MoreExamplesAndTemplates') : translate('ExamplesAndTemplates') ) : page === 'trash' ? 'Trash' : workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)] @@ -268,7 +270,7 @@ function buildOtherSites(home: HomeModel) { return css.otherSitesBlock( dom.autoDispose(hideOtherSitesObs), css.otherSitesHeader( - t('DocMenu.OtherSites'), + translate('OtherSites'), dom.domComputed(hideOtherSitesObs, (collapsed) => collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse') ), @@ -280,7 +282,7 @@ function buildOtherSites(home: HomeModel) { const siteName = home.app.currentOrgName; return [ dom('div', - t('DocMenu.OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), + translate('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), testId('other-sites-message') ), css.otherSitesButtons( diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index b47be7f9..02f4d812 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -14,6 +14,7 @@ import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; import {Computed, dom, DomContents, styled} from 'grainjs'; +const translate = (x: string, args?: any): string => t(`HomeIntro.${x}`, args); export function buildHomeIntro(homeModel: HomeModel): DomContents { const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER; @@ -110,9 +111,9 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) { } function makeAnonIntro(homeModel: HomeModel) { - const signUp = cssLink({href: getLoginOrSignupUrl()}, t('HomeIntro.SignUp')); + const signUp = cssLink({href: getLoginOrSignupUrl()}, translate('SignUp')); return [ - css.docListHeader(t('HomeIntro.Welcome'), testId('welcome-title')), + 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.']), diff --git a/app/server/lib/sendAppPage.ts b/app/server/lib/sendAppPage.ts index 892297cb..377709b0 100644 --- a/app/server/lib/sendAppPage.ts +++ b/app/server/lib/sendAppPage.ts @@ -12,6 +12,8 @@ import jsesc from 'jsesc'; import * as handlebars from 'handlebars'; import * as path from 'path'; +const translate = (req: express.Request, key: string, args?: any) => req.t(`sendAppPage.${key}`, args); + export interface ISendAppPageOptions { path: string; // Ignored if .content is present (set to "" for clarity). content?: string; @@ -154,7 +156,7 @@ function configuredPageTitleSuffix() { */ function getPageTitle(req: express.Request, config: GristLoadConfig): string { const maybeDoc = getDocFromConfig(config); - if (!maybeDoc) { return req.t('sendAppPage.Loading') + "..."; } + if (!maybeDoc) { return translate(req, 'sendAppPage.Loading') + "..."; } return handlebars.Utils.escapeExpression(maybeDoc.name); } From bac3067719624fb6d812f9ebc0554fd0aa48153d Mon Sep 17 00:00:00 2001 From: Arnaud Peich Date: Wed, 19 Oct 2022 15:45:19 +0200 Subject: [PATCH 4/7] Fix condition to check that a translation key exists --- app/client/lib/localization.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/client/lib/localization.ts b/app/client/lib/localization.ts index 102184b6..fb81ad0c 100644 --- a/app/client/lib/localization.ts +++ b/app/client/lib/localization.ts @@ -71,7 +71,7 @@ export async function setupLocale() { * Resolves the translation of the given key, using the given options. */ export function t(key: string, args?: any): string { - if (!i18next.exists(key)) { + if (!i18next.exists(key, args)) { const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); reportError(error); } From 2586b595a56f4ada9bf879687e7c12cbea11432e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Wed, 19 Oct 2022 13:43:55 +0200 Subject: [PATCH 5/7] Adding domT method for component interpolation --- app/client/lib/localization.ts | 60 ++++++++++++++++++++++++++++++--- app/client/ui/HomeIntro.ts | 8 ++--- static/locales/en.client.json | 3 +- test/client/lib/localization.ts | 56 ++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 9 deletions(-) create mode 100644 test/client/lib/localization.ts 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], '.'); + }); +}); From 2f29df1b17658bf43a58428503e1153022d69efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Wed, 19 Oct 2022 20:44:56 +0200 Subject: [PATCH 6/7] Changing domT to a default translation function for browser --- app/client/lib/localization.ts | 21 +++++++++---------- app/client/ui/AddNewButton.ts | 2 +- app/client/ui/DocMenu.ts | 2 +- app/client/ui/HomeIntro.ts | 8 +++---- documentation/translations.md | 11 ++++++++++ test/client/lib/localization.ts | 37 +++++++++++++++++++++++++++------ 6 files changed, 58 insertions(+), 23 deletions(-) diff --git a/app/client/lib/localization.ts b/app/client/lib/localization.ts index 4c03954a..91b7f51f 100644 --- a/app/client/lib/localization.ts +++ b/app/client/lib/localization.ts @@ -1,7 +1,7 @@ import {getGristConfig} from 'app/common/urlUtils'; import {DomContents} from 'grainjs'; -import {G} from 'grainjs/dist/cjs/lib/browserGlobals'; import i18next from 'i18next'; +import {G} from 'grainjs/dist/cjs/lib/browserGlobals'; export async function setupLocale() { const now = Date.now(); @@ -72,8 +72,8 @@ export async function setupLocale() { /** * Resolves the translation of the given key using the given options. */ -export function t(key: string, args?: any, instance = i18next): string { - if (!instance.exists(key, args)) { +export function tString(key: string, args?: any, instance = i18next): string { + if (!instance.exists(key, args || undefined)) { const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); reportError(error); } @@ -84,15 +84,15 @@ export function t(key: string, args?: any, instance = i18next): string { /** * 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)) { + export function t(key: string, args?: any, instance = i18next): DomContents { + if (!instance.exists(key, args || undefined)) { 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)); + const domElements = !args ? [] : Object.entries(args).filter(([_, value]) => isLikeDomContents(value)); if (!args || !domElements.length) { - return t(key, args); + return instance.t(key, args || undefined) as string; } else { // Make a copy of the arguments, and remove any dom elements from it. It will instruct // i18next library to use `missingInterpolationHandler` handler. @@ -104,9 +104,9 @@ export function t(key: string, args?: any, instance = i18next): string { 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+)`)); + const parts = result.split(/(\[\[\[[^\]]+?\]\]\])/); for (let i = 1; i < parts.length; i += 2) { // Every second element is our dom element. - const propName = parts[i].replace(DOM_MARKER, ""); + const propName = parts[i].substring(3, parts[i].length - 3); const domElement = args[propName] ?? `{{${propName}}}`; // If the prop is not there, simulate default behavior. parts[i] = domElement; } @@ -121,9 +121,8 @@ export function hasTranslation(key: string) { return i18next.exists(key); } -const DOM_MARKER = '__domKey_'; function missingInterpolationHandler(key: string, value: any) { - return `${DOM_MARKER}${value[1]}`; + return `[[[${value[1]}]]]`; } /** diff --git a/app/client/ui/AddNewButton.ts b/app/client/ui/AddNewButton.ts index 7e792c6a..588f32e0 100644 --- a/app/client/ui/AddNewButton.ts +++ b/app/client/ui/AddNewButton.ts @@ -3,7 +3,7 @@ import {icon} from 'app/client/ui2018/icons'; import {dom, DomElementArg, Observable, styled} from "grainjs"; import {t} from 'app/client/lib/localization'; -const translate = (x: string, args?: any): string => t(`AddNewButton.${x}`, args); +const translate = (x: string, args?: any) => t(`AddNewButton.${x}`, args); export function addNewButton(isOpen: Observable | boolean = true, ...args: DomElementArg[]) { return cssAddNewButton( diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index 28ec71ea..86d1982e 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -34,7 +34,7 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {bigBasicButton} from 'app/client/ui2018/buttons'; import sortBy = require('lodash/sortBy'); -const translate = (x: string, args?: any): string => t(`DocMenu.${x}`, args); +const translate = (x: string, args?: any) => t(`DocMenu.${x}`, args); const testId = makeTestId('test-dm-'); diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index 2e27c2ed..d341c104 100644 --- a/app/client/ui/HomeIntro.ts +++ b/app/client/ui/HomeIntro.ts @@ -1,4 +1,4 @@ -import {domT, t} from 'app/client/lib/localization'; +import {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'; @@ -14,7 +14,7 @@ import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; import {Computed, dom, DomContents, styled} from 'grainjs'; -const translate = (x: string, args?: any): string => t(`HomeIntro.${x}`, args); +const translate = (x: string, args?: any) => t(`HomeIntro.${x}`, args); export function buildHomeIntro(homeModel: HomeModel): DomContents { const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER; @@ -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(domT('HomeIntro.VisitHelpCenter', { link: helpCenterLink() }), + cssIntroLine(t('HomeIntro.VisitHelpCenter', { link: helpCenterLink() }), testId('welcome-text')) ), makeCreateButtons(homeModel), @@ -116,7 +116,7 @@ function makeAnonIntro(homeModel: HomeModel) { 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 : domT('HomeIntro.VisitHelpCenter', { link: helpCenterLink() })), + (shouldHideUiElement('helpCenter') ? null : t('HomeIntro.VisitHelpCenter', { link: helpCenterLink() })), testId('welcome-text')), makeCreateButtons(homeModel), ]; diff --git a/documentation/translations.md b/documentation/translations.md index 36bc2898..a82b91e0 100644 --- a/documentation/translations.md +++ b/documentation/translations.md @@ -101,6 +101,17 @@ function makeAnonIntro(homeModel: HomeModel) { css.docListHeader(t('HomeIntro.Welcome'), testId('welcome-title')), ``` + +Function `t` on the client side is also able to use `DomContents` values (so material produced by +the GrainJS library) for interpolation. For example: + +```ts +dom('span', t('Argument', { + arg1: dom('span', 'First'), + arg2: dom.domComputed(obs, (value) => dom('span', value)) +})); +``` + Some things are not supported at this moment and will need to be addressed in future development tasks: diff --git a/test/client/lib/localization.ts b/test/client/lib/localization.ts index ccd1fa7e..c0892088 100644 --- a/test/client/lib/localization.ts +++ b/test/client/lib/localization.ts @@ -1,7 +1,7 @@ -import {domT, t} from 'app/client/lib/localization'; +import {t} from 'app/client/lib/localization'; import {assert} from 'chai'; import i18next, {i18n} from 'i18next'; -import {dom} from "grainjs"; +import {dom, observable} from "grainjs"; import {popGlobals, pushGlobals, G} from 'grainjs/dist/cjs/lib/browserGlobals'; import {JSDOM} from 'jsdom'; @@ -26,31 +26,56 @@ describe('localization', function() { translation: { 'Text': 'TranslatedText', 'Argument': 'Translated {{arg1}} {{arg2}}.', + 'Argument_variant': 'Variant {{arg1}} {{arg2}}.', } } } }); }); - it('supports basic operation', function() { - assert.equal(t('Text', null, instance), 'TranslatedText'); + it('supports basic operation for strings', function() { assert.equal(t('Argument', {arg1: '1', arg2: '2'}, instance), 'Translated 1 2.'); + assert.equal(t('Argument', {arg1: '1', arg2: '2', context: 'variant'}, instance), 'Variant 1 2.'); + assert.equal(t('Text', null, instance), 'TranslatedText'); }); it('supports dom content interpolation', function() { - const result = domT('Argument', { + const obs = observable("Second"); + const result = t('Argument', { arg1: dom('span', 'First'), - arg2: dom.domComputed("test", (value) => dom('span', value)) + arg2: dom.domComputed(obs, (value) => dom('span', value)) }, instance) as any; assert.isTrue(Array.isArray(result)); assert.equal(result.length, 5); + // First we have a plain string. assert.equal(result[0], 'Translated '); + // Next we have a span element. assert.equal(result[1]?.tagName, 'SPAN'); assert.equal(result[1]?.textContent, 'First'); + // Empty space 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.isTrue(result[3][1] instanceof G.Node); + assert.isTrue(typeof result[3][2] === 'function'); + // As last we have "." assert.equal(result[4], '.'); + + // Make sure that computed works. + const span = dom('span', result); + assert.equal(span.textContent, "Translated First Second."); + obs.set("Third"); + assert.equal(span.textContent, "Translated First Third."); + + // Test that context variable works. + const variantSpan = dom('span', t('Argument', { + arg1: dom('span', 'First'), + arg2: dom.domComputed(obs, (value) => dom('span', value)), + context: 'variant' + }, instance)); + assert.equal(variantSpan.textContent, "Variant First Third."); + obs.set("Fourth"); + assert.equal(variantSpan.textContent, "Variant First Fourth."); }); }); From 18ba7994ed54383ad4e13553e8eba99bed6a0a14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Thu, 20 Oct 2022 10:34:38 +0200 Subject: [PATCH 7/7] Adding type inference and makeT helper function --- app/client/lib/localization.ts | 19 +++++-- app/client/ui/AddNewButton.ts | 4 +- app/client/ui/DocMenu.ts | 4 +- app/client/ui/HomeIntro.ts | 8 +-- test/client/lib/localization.ts | 91 ++++++++++++++++++++++++--------- 5 files changed, 91 insertions(+), 35 deletions(-) diff --git a/app/client/lib/localization.ts b/app/client/lib/localization.ts index 91b7f51f..a628a361 100644 --- a/app/client/lib/localization.ts +++ b/app/client/lib/localization.ts @@ -80,11 +80,15 @@ export function tString(key: string, args?: any, instance = i18next): string { return instance.t(key, args); } +// We will try to infer result from the arguments passed to `t` function. +// For plain objects we expect string as a result. If any property doesn't look as a plain value +// we assume that it might be a dom node and the result is DomContents. +type InferResult = T extends Record|undefined|null ? string : DomContents; /** * Resolves the translation of the given key and substitutes. Supports dom elements interpolation. */ - export function t(key: string, args?: any, instance = i18next): DomContents { + export function t>(key: string, args?: T|null, instance = i18next): InferResult { if (!instance.exists(key, args || undefined)) { const error = new Error(`Missing translation for key: ${key} and language: ${i18next.language}`); reportError(error); @@ -92,7 +96,7 @@ export function tString(key: string, args?: any, instance = i18next): string { // If there are any DomElements in args, handle it with missingInterpolationHandler. const domElements = !args ? [] : Object.entries(args).filter(([_, value]) => isLikeDomContents(value)); if (!args || !domElements.length) { - return instance.t(key, args || undefined) as string; + return instance.t(key, args || undefined) as any; } else { // Make a copy of the arguments, and remove any dom elements from it. It will instruct // i18next library to use `missingInterpolationHandler` handler. @@ -110,7 +114,7 @@ export function tString(key: string, args?: any, instance = i18next): string { const domElement = args[propName] ?? `{{${propName}}}`; // If the prop is not there, simulate default behavior. parts[i] = domElement; } - return parts; + return parts.filter(p => p !== '') as any; // Remove empty parts. } } @@ -135,3 +139,12 @@ function isLikeDomContents(value: any): boolean { (Array.isArray(value) && isLikeDomContents(value[0])) || // DomComputed typeof value === 'function'; // DomMethod } + +/** + * Helper function to create scoped t function. + */ +export function makeT(scope: string) { + return function>(key: string, args?: T|null, instance = i18next) { + return t(`${scope}.${key}`, args, instance); + } +} diff --git a/app/client/ui/AddNewButton.ts b/app/client/ui/AddNewButton.ts index 588f32e0..9c45a5f6 100644 --- a/app/client/ui/AddNewButton.ts +++ b/app/client/ui/AddNewButton.ts @@ -1,9 +1,9 @@ import {theme, vars} from 'app/client/ui2018/cssVars'; +import {makeT} from 'app/client/lib/localization'; import {icon} from 'app/client/ui2018/icons'; import {dom, DomElementArg, Observable, styled} from "grainjs"; -import {t} from 'app/client/lib/localization'; -const translate = (x: string, args?: any) => t(`AddNewButton.${x}`, args); +const translate = makeT(`AddNewButton`); export function addNewButton(isOpen: Observable | boolean = true, ...args: DomElementArg[]) { return cssAddNewButton( diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index 86d1982e..e7b10aa7 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -29,12 +29,12 @@ import {Document, Workspace} from 'app/common/UserAPI'; import {computed, Computed, dom, DomArg, DomContents, IDisposableOwner, makeTestId, observable, Observable} from 'grainjs'; import {buildTemplateDocs} from 'app/client/ui/TemplateDocs'; -import {t} from 'app/client/lib/localization'; +import {makeT} from 'app/client/lib/localization'; import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; import {bigBasicButton} from 'app/client/ui2018/buttons'; import sortBy = require('lodash/sortBy'); -const translate = (x: string, args?: any) => t(`DocMenu.${x}`, args); +const translate = makeT(`DocMenu`); const testId = makeTestId('test-dm-'); diff --git a/app/client/ui/HomeIntro.ts b/app/client/ui/HomeIntro.ts index d341c104..3819da12 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 {makeT} 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'; @@ -14,7 +14,7 @@ import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; import {Computed, dom, DomContents, styled} from 'grainjs'; -const translate = (x: string, args?: any) => t(`HomeIntro.${x}`, args); +const translate = makeT('HomeIntro'); export function buildHomeIntro(homeModel: HomeModel): DomContents { const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER; @@ -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(t('HomeIntro.VisitHelpCenter', { link: helpCenterLink() }), + cssIntroLine(translate('VisitHelpCenter', { link: helpCenterLink() }), testId('welcome-text')) ), makeCreateButtons(homeModel), @@ -116,7 +116,7 @@ function makeAnonIntro(homeModel: HomeModel) { 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 : t('HomeIntro.VisitHelpCenter', { link: helpCenterLink() })), + (shouldHideUiElement('helpCenter') ? null : translate('VisitHelpCenter', { link: helpCenterLink() })), testId('welcome-text')), makeCreateButtons(homeModel), ]; diff --git a/test/client/lib/localization.ts b/test/client/lib/localization.ts index c0892088..0e64f08f 100644 --- a/test/client/lib/localization.ts +++ b/test/client/lib/localization.ts @@ -1,11 +1,31 @@ -import {t} from 'app/client/lib/localization'; +import {makeT, t} from 'app/client/lib/localization'; import {assert} from 'chai'; import i18next, {i18n} from 'i18next'; -import {dom, observable} from "grainjs"; +import {Disposable, dom, DomContents, observable} from "grainjs"; import {popGlobals, pushGlobals, G} from 'grainjs/dist/cjs/lib/browserGlobals'; import {JSDOM} from 'jsdom'; describe('localization', function() { + let instance: i18n; + before(() => { + instance = i18next.createInstance(); + instance.init({ + lng: 'en', + resources: { + en: { + translation: { + 'Text': 'TranslatedText', + 'Argument': 'Translated {{arg1}} {{arg2}}{{end}}', + 'Argument_variant': 'Variant {{arg1}} {{arg2}}{{end}}', + 'Parent': { + 'Child': 'Translated child {{arg}}', + } + } + } + } + }); + }); + beforeEach(function() { // These grainjs browserGlobals are needed for using dom() in tests. const jsdomDoc = new JSDOM(""); @@ -16,34 +36,23 @@ describe('localization', function() { popGlobals(); }); - let instance: i18n; - before(() => { - instance = i18next.createInstance(); - instance.init({ - lng: 'en', - resources: { - en: { - translation: { - 'Text': 'TranslatedText', - 'Argument': 'Translated {{arg1}} {{arg2}}.', - 'Argument_variant': 'Variant {{arg1}} {{arg2}}.', - } - } - } - }); - }); - it('supports basic operation for strings', function() { - assert.equal(t('Argument', {arg1: '1', arg2: '2'}, instance), 'Translated 1 2.'); - assert.equal(t('Argument', {arg1: '1', arg2: '2', context: 'variant'}, instance), 'Variant 1 2.'); + assert.equal(t('Argument', {arg1: '1', arg2: '2', end: '.'}, instance), 'Translated 1 2.'); + assert.equal(t('Argument', {arg1: '1', arg2: '2', end: '.', context: 'variant'}, instance), 'Variant 1 2.'); assert.equal(t('Text', null, instance), 'TranslatedText'); }); it('supports dom content interpolation', function() { + class Component extends Disposable { + public buildDom() { + return dom('span', '.'); + } + } const obs = observable("Second"); const result = t('Argument', { arg1: dom('span', 'First'), - arg2: dom.domComputed(obs, (value) => dom('span', value)) + arg2: dom.domComputed(obs, (value) => dom('span', value)), + end: dom.create(Component) }, instance) as any; assert.isTrue(Array.isArray(result)); assert.equal(result.length, 5); @@ -59,8 +68,11 @@ describe('localization', function() { assert.isTrue(result[3][0] instanceof G.Node); assert.isTrue(result[3][1] instanceof G.Node); assert.isTrue(typeof result[3][2] === 'function'); - // As last we have "." - assert.equal(result[4], '.'); + // As last we have "." as grainjs component. + assert.isTrue(Array.isArray(result[4])); + assert.isTrue(result[4][0] instanceof G.Node); + assert.isTrue(result[4][1] instanceof G.Node); + assert.isTrue(typeof result[4][2] === 'function'); // Make sure that computed works. const span = dom('span', result); @@ -72,10 +84,41 @@ describe('localization', function() { const variantSpan = dom('span', t('Argument', { arg1: dom('span', 'First'), arg2: dom.domComputed(obs, (value) => dom('span', value)), + end: dom.create(Component), context: 'variant' }, instance)); assert.equal(variantSpan.textContent, "Variant First Third."); obs.set("Fourth"); assert.equal(variantSpan.textContent, "Variant First Fourth."); }); + + it('supports scoping through makeT', function() { + const scoped = makeT('Parent'); + assert.equal(scoped('Child', { arg : 'Arg'}, instance), 'Translated child Arg'); + }); + + it('infers result from parameters', function() { + class Component extends Disposable { + public buildDom() { + return dom('span', '.'); + } + } + // Here we only test that this "compiles" without errors and types are correct. + let typeString: string = ''; void typeString; + typeString = t('Argument', {arg1: 'argument 1', arg2: 'argument 2'}, instance); + typeString = t('Argument', {arg1: 1, arg2: true}, instance); + typeString = t('Argument', undefined, instance); + const scoped = makeT('Parent'); + typeString = scoped('Child', {arg: 'argument 1'}, instance); + typeString = scoped('Child', {arg: 1}, instance); + typeString = scoped('Child', undefined, instance); + + let domContent: DomContents = null; void domContent; + + domContent = t('Argument', {arg1: 'argument 1', arg2: dom('span')}, instance); + domContent = t('Argument', {arg1: 1, arg2: dom.domComputed(observable('test'))}, instance); + domContent = t('Argument', undefined, instance); + domContent = scoped('Child', {arg: dom.create(Component)}, instance); + domContent = scoped('Child', {arg: dom.maybe(observable(true), () => dom('span'))}, instance); + }); });