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:
jarek 2022-10-19 10:38:39 +02:00 committed by GitHub
commit 4bb1d8c011
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 86 additions and 60 deletions

View File

@ -15,7 +15,7 @@ export async function setupLocale() {
} }
} }
const ns = getGristConfig().namespaces ?? ['core']; const ns = getGristConfig().namespaces ?? ['client'];
// Initialize localization plugin // Initialize localization plugin
try { try {
// We don't await this promise, as it is resolved synchronously due to initImmediate: false. // 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, initImmediate: false,
// Read language from navigator object. // Read language from navigator object.
lng, lng,
// By default we use core namespace. // By default we use client namespace.
defaultNS: 'core', defaultNS: 'client',
// Read namespaces that are supported by the server. // Read namespaces that are supported by the server.
// TODO: this can be converted to a dynamic list of namespaces, for async components. // TODO: this can be converted to a dynamic list of namespaces, for async components.
// for now just import all what server offers. // for now just import all what server offers.
// We can fallback to core namespace for any addons. // We can fallback to client namespace for any addons.
fallbackNS: 'core', fallbackNS: 'client',
ns, ns,
supportedLngs supportedLngs
}).catch((err: any) => { }).catch((err: any) => {

View File

@ -3,12 +3,14 @@ import {icon} from 'app/client/ui2018/icons';
import {dom, DomElementArg, Observable, styled} from "grainjs"; import {dom, DomElementArg, Observable, styled} from "grainjs";
import {t} from 'app/client/lib/localization'; 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[]) { export function addNewButton(isOpen: Observable<boolean> | boolean = true, ...args: DomElementArg[]) {
return cssAddNewButton( return cssAddNewButton(
cssAddNewButton.cls('-open', isOpen), cssAddNewButton.cls('-open', isOpen),
// Setting spacing as flex items allows them to shrink faster when there isn't enough space. // Setting spacing as flex items allows them to shrink faster when there isn't enough space.
cssLeftMargin(), cssLeftMargin(),
cssAddText(t('AddNew')), cssAddText(translate('AddNew')),
dom('div', {style: 'flex: 1 1 16px'}), dom('div', {style: 'flex: 1 1 16px'}),
cssPlusButton(cssPlusIcon('Plus')), cssPlusButton(cssPlusIcon('Plus')),
dom('div', {style: 'flex: 0 1 16px'}), dom('div', {style: 'flex: 0 1 16px'}),

View File

@ -34,6 +34,8 @@ import {localStorageBoolObs} from 'app/client/lib/localStorageObs';
import {bigBasicButton} from 'app/client/ui2018/buttons'; import {bigBasicButton} from 'app/client/ui2018/buttons';
import sortBy = require('lodash/sortBy'); import sortBy = require('lodash/sortBy');
const translate = (x: string, args?: any): string => t(`DocMenu.${x}`, args);
const testId = makeTestId('test-dm-'); const testId = makeTestId('test-dm-');
/** /**
@ -105,10 +107,10 @@ function createLoadedDocMenu(owner: IDisposableOwner, home: HomeModel) {
null : null :
css.docListHeader( css.docListHeader(
( (
page === 'all' ? t('AllDocuments') : page === 'all' ? translate('AllDocuments') :
page === 'templates' ? page === 'templates' ?
dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) => dom.domComputed(use => use(home.featuredTemplates).length > 0, (hasFeaturedTemplates) =>
hasFeaturedTemplates ? t('MoreExamplesAndTemplates') : t('ExamplesAndTemplates') hasFeaturedTemplates ? translate('MoreExamplesAndTemplates') : translate('ExamplesAndTemplates')
) : ) :
page === 'trash' ? 'Trash' : page === 'trash' ? 'Trash' :
workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)] workspace && [css.docHeaderIcon('Folder'), workspaceName(home.app, workspace)]
@ -268,7 +270,7 @@ function buildOtherSites(home: HomeModel) {
return css.otherSitesBlock( return css.otherSitesBlock(
dom.autoDispose(hideOtherSitesObs), dom.autoDispose(hideOtherSitesObs),
css.otherSitesHeader( css.otherSitesHeader(
t('OtherSites'), translate('OtherSites'),
dom.domComputed(hideOtherSitesObs, (collapsed) => dom.domComputed(hideOtherSitesObs, (collapsed) =>
collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse') collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse')
), ),
@ -280,7 +282,7 @@ function buildOtherSites(home: HomeModel) {
const siteName = home.app.currentOrgName; const siteName = home.app.currentOrgName;
return [ return [
dom('div', dom('div',
t('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), translate('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }),
testId('other-sites-message') testId('other-sites-message')
), ),
css.otherSitesButtons( css.otherSitesButtons(

View File

@ -14,6 +14,7 @@ import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {Computed, dom, DomContents, styled} from 'grainjs'; import {Computed, dom, DomContents, styled} from 'grainjs';
const translate = (x: string, args?: any): string => t(`HomeIntro.${x}`, args);
export function buildHomeIntro(homeModel: HomeModel): DomContents { export function buildHomeIntro(homeModel: HomeModel): DomContents {
const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER; const isViewer = homeModel.app.currentOrg?.access === roles.VIEWER;
@ -110,9 +111,9 @@ function makePersonalIntro(homeModel: HomeModel, user: FullUser) {
} }
function makeAnonIntro(homeModel: HomeModel) { function makeAnonIntro(homeModel: HomeModel) {
const signUp = cssLink({href: getLoginOrSignupUrl()}, t('SignUp')); const signUp = cssLink({href: getLoginOrSignupUrl()}, translate('SignUp'));
return [ 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('Get started by exploring templates, or creating your first Grist document.'),
cssIntroLine(signUp, ' to save your work.', cssIntroLine(signUp, ' to save your work.',
(shouldHideUiElement('helpCenter') ? null : [' Visit our ', helpCenterLink(), ' to learn more.']), (shouldHideUiElement('helpCenter') ? null : [' Visit our ', helpCenterLink(), ' to learn more.']),

View File

@ -12,6 +12,8 @@ import jsesc from 'jsesc';
import * as handlebars from 'handlebars'; import * as handlebars from 'handlebars';
import * as path from 'path'; import * as path from 'path';
const translate = (req: express.Request, key: string, args?: any) => req.t(`sendAppPage.${key}`, args);
export interface ISendAppPageOptions { export interface ISendAppPageOptions {
path: string; // Ignored if .content is present (set to "" for clarity). path: string; // Ignored if .content is present (set to "" for clarity).
content?: string; content?: string;
@ -155,7 +157,7 @@ function configuredPageTitleSuffix() {
*/ */
function getPageTitle(req: express.Request, config: GristLoadConfig): string { function getPageTitle(req: express.Request, config: GristLoadConfig): string {
const maybeDoc = getDocFromConfig(config); const maybeDoc = getDocFromConfig(config);
if (!maybeDoc) { return req.t('Loading') + "..."; } if (!maybeDoc) { return translate(req, 'sendAppPage.Loading') + "..."; }
return handlebars.Utils.escapeExpression(maybeDoc.name); return handlebars.Utils.escapeExpression(maybeDoc.name);
} }

View File

@ -26,8 +26,8 @@ export function setupLocale(appRoot: string): i18n {
supportedNamespaces.add(namespace); supportedNamespaces.add(namespace);
return lang; return lang;
}).filter((lang) => lang)); }).filter((lang) => lang));
if (!supportedLngs.has('en') || !supportedNamespaces.has('core')) { if (!supportedLngs.has('en') || !supportedNamespaces.has('server')) {
throw new Error("Missing core English language file"); throw new Error("Missing server English language file");
} }
// Initialize localization filesystem plugin that will read the locale files from the localeDir. // Initialize localization filesystem plugin that will read the locale files from the localeDir.
instance.use(i18fsBackend); instance.use(i18fsBackend);
@ -40,7 +40,7 @@ export function setupLocale(appRoot: string): i18n {
initImmediate: false, initImmediate: false,
preload: [...supportedLngs], preload: [...supportedLngs],
supportedLngs: [...supportedLngs], supportedLngs: [...supportedLngs],
defaultNS: 'core', defaultNS: 'server',
ns: [...supportedNamespaces], ns: [...supportedNamespaces],
fallbackLng: 'en', fallbackLng: 'en',
backend: { backend: {
@ -71,5 +71,5 @@ export function readLoadedNamespaces(instance?: i18n): readonly string[] {
if (Array.isArray(instance?.options.ns)) { if (Array.isArray(instance?.options.ns)) {
return 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'];
} }

View File

@ -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 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 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 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 ```json
{ {
"Welcome": "Welcome to Grist!", "AddNewButton": {
"Loading": "Loading", "AddNew": "Add New"
"AddNew": "Add New", },
"DocMenu": {
"OtherSites": "Other Sites", "OtherSites": "Other Sites",
"OtherSitesWelcome": "Your are on {{siteName}}. You also have access to the following sites:", "OtherSitesWelcome": "You are on the {{siteName}} site. You also have access to the following sites:",
"OtherSitesWelcome_personal": "Your are on your personal 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", "AllDocuments": "All Documents",
"ExamplesAndTemplates": "Examples and Templates", "ExamplesAndTemplates": "Examples and Templates",
"MoreExamplesAndTemplates": "More 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 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 found at https://www.i18next.com/translation-function/interpolation and
https://www.i18next.com/translation-function/context. https://www.i18next.com/translation-function/context.
Both client and server code (node.js) use the same resource files. A resource file name format Client and server code (node.js) use separate resource files. A resource file name format
follows a pattern: [language code].[product].json (i.e. `pl-Pl.core.json`, `en-US.core.json`, follows a pattern: [language code].[product].json (e.g. `pl-Pl.client.json`, `en-US.client.json`,
`en.core.json`). Grist can be packaged as several different products, and each product can have its `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` own translation files that are added to the core. Products are supported by leveraging `i18next`
feature called `namespaces` https://www.i18next.com/principles/namespaces. 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 ## Translation instruction
### Client ### 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'. The entry point for all translations is a function exported from 'app/client/lib/localization'.
```ts ```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 It is a wrapper around `i18next` exported method with the same interface
@ -71,7 +80,7 @@ _app/client/ui.DocMenu.ts_
```ts ```ts
css.otherSitesHeader( css.otherSitesHeader(
t('OtherSites'), t('DocMenu.OtherSites'),
..... .....
), ),
dom.maybe((use) => !use(hideOtherSitesObs), () => { dom.maybe((use) => !use(hideOtherSitesObs), () => {
@ -79,7 +88,7 @@ _app/client/ui.DocMenu.ts_
const siteName = home.app.currentOrgName; const siteName = home.app.currentOrgName;
return [ return [
dom('div', dom('div',
t('OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }), t('DocMenu.OtherSitesWelcome', { siteName, context: personal ? 'personal' : '' }),
testId('other-sites-message') testId('other-sites-message')
``` ```
@ -87,9 +96,9 @@ _app/client/ui/HomeIntro.ts_
```ts ```ts
function makeAnonIntro(homeModel: HomeModel) { function makeAnonIntro(homeModel: HomeModel) {
const signUp = cssLink({href: getLoginOrSignupUrl()}, t('SignUp')); const signUp = cssLink({href: getLoginOrSignupUrl()}, t('HomeIntro.SignUp'));
return [ 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 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 { function getPageTitle(req: express.Request, config: GristLoadConfig): string {
const maybeDoc = getDocFromConfig(config); const maybeDoc = getDocFromConfig(config);
if (!maybeDoc) { if (!maybeDoc) {
return req.t('Loading') + '...'; return req.t('sendAppPage.Loading') + '...';
} }
return handlebars.Utils.escapeExpression(maybeDoc.name); return handlebars.Utils.escapeExpression(maybeDoc.name);
@ -133,8 +142,8 @@ function getPageTitle(req: express.Request, config: GristLoadConfig): string {
### Next steps ### Next steps
- Annotate all client code and create all resource files in `en.core.json` file. Almost all static - Annotate all client code and create all resource files in `en.client.json` and `en.server.json` files.
text is ready for translation. 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. - 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 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. **WidgetFrame** component so that it can pass current user language to the hosted widget page.

View 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"
}
}

View File

@ -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"
}

View File

@ -0,0 +1,5 @@
{
"sendAppPage": {
"Loading": "Loading"
}
}

View File

@ -23,8 +23,8 @@ describe("Localization", function() {
// Grist config should contain the list of supported languages; // Grist config should contain the list of supported languages;
const gristConfig: any = await driver.executeScript("return window.gristConfig"); const gristConfig: any = await driver.executeScript("return window.gristConfig");
// core and en is required. // client and en is required.
assert.isTrue(gristConfig.namespaces.includes("core")); assert.isTrue(gristConfig.namespaces.includes("client"));
assert.isTrue(gristConfig.supportedLngs.includes("en")); assert.isTrue(gristConfig.supportedLngs.includes("en"));
}); });
@ -81,13 +81,13 @@ describe("Localization", function() {
function present(response: string, ...langs: string[]) { function present(response: string, ...langs: string[]) {
for (const lang of langs) { 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[]) { function notPresent(response: string, ...langs: string[]) {
for (const lang of langs) { 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() { it("loads correct languages from file system", async function() {
modifyByCode(tempLocale, "en", {Welcome: 'TestMessage'}); modifyByCode(tempLocale, "en", {HomeIntro: {Welcome: 'TestMessage'}});
await driver.navigate().refresh(); await driver.navigate().refresh();
assert.equal(await driver.findWait('.test-welcome-title', 3000).getText(), 'TestMessage'); assert.equal(await driver.findWait('.test-welcome-title', 3000).getText(), 'TestMessage');
const gristConfig: any = await driver.executeScript("return window.gristConfig"); const gristConfig: any = await driver.executeScript("return window.gristConfig");
@ -163,8 +163,8 @@ describe("Localization", function() {
} }
function modifyByCode(localeDir: string, code: string, obj: any) { function modifyByCode(localeDir: string, code: string, obj: any) {
// Read current core localization file. // Read current client localization file.
const filePath = path.join(localeDir, `${code}.core.json`); const filePath = path.join(localeDir, `${code}.client.json`);
const resources = JSON.parse(fs.readFileSync(filePath).toString()); const resources = JSON.parse(fs.readFileSync(filePath).toString());
const newResource = Object.assign(resources, obj); const newResource = Object.assign(resources, obj);
fs.writeFileSync(filePath, JSON.stringify(newResource)); fs.writeFileSync(filePath, JSON.stringify(newResource));