mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	Merge pull request #312 from incubateur-territoires/arnaudpeich/Split_client_and_server_translations_organize_by_filename
Split client and server translations, organize by filename
This commit is contained in:
		
						commit
						4bb1d8c011
					
				| @ -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) => { | ||||
|  | ||||
| @ -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> | 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('AddNew')), | ||||
|     cssAddText(translate('AddNew')), | ||||
|     dom('div', {style: 'flex: 1 1 16px'}), | ||||
|     cssPlusButton(cssPlusIcon('Plus')), | ||||
|     dom('div', {style: 'flex: 0 1 16px'}), | ||||
|  | ||||
| @ -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('AllDocuments') : | ||||
|                     page === 'all' ? translate('AllDocuments') : | ||||
|                     page === 'templates' ? | ||||
|                       dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) => | ||||
|                         hasFeaturedTemplates ? t('MoreExamplesAndTemplates') : t('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('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('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), | ||||
|             translate('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), | ||||
|             testId('other-sites-message') | ||||
|           ), | ||||
|           css.otherSitesButtons( | ||||
|  | ||||
| @ -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('SignUp')); | ||||
|   const signUp = cssLink({href: getLoginOrSignupUrl()}, translate('SignUp')); | ||||
|   return [ | ||||
|     css.docListHeader(t('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.']), | ||||
|  | ||||
| @ -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; | ||||
| @ -155,7 +157,7 @@ function configuredPageTitleSuffix() { | ||||
|  */ | ||||
| function getPageTitle(req: express.Request, config: GristLoadConfig): string { | ||||
|   const maybeDoc = getDocFromConfig(config); | ||||
|   if (!maybeDoc) { return req.t('Loading') + "..."; } | ||||
|   if (!maybeDoc) { return translate(req, 'sendAppPage.Loading') + "..."; } | ||||
| 
 | ||||
|   return handlebars.Utils.escapeExpression(maybeDoc.name); | ||||
| } | ||||
|  | ||||
| @ -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']; | ||||
| } | ||||
|  | ||||
| @ -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. | ||||
|  | ||||
							
								
								
									
										17
									
								
								static/locales/en.client.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								static/locales/en.client.json
									
									
									
									
									
										Normal file
									
								
							| @ -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" | ||||
|   } | ||||
| } | ||||
| @ -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" | ||||
| } | ||||
							
								
								
									
										5
									
								
								static/locales/en.server.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								static/locales/en.server.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| { | ||||
|   "sendAppPage": { | ||||
|     "Loading": "Loading" | ||||
|   } | ||||
| } | ||||
| @ -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)); | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user