Skip to content

Commit

Permalink
Add localization (umputun#602)
Browse files Browse the repository at this point in the history
* umputun#10 add localization

* umputun#10 revert locale from dev page

* umputun#10 add more examples

* umputun#10 localize auth panel

* umputun#10 localize some comment message

* umputun#10 localize some comment form message

* umputun#10 translate vote messages

* umputun#10 translate comment message

* umputun#10 use messages value for translate reference

* umputun#10 fix compile issue

* umputun#10 use messages value for translate reference

* umputun#10 use messages value for translate reference

* umputun#10 use messages value for translate reference

* umputun#10 use messages value for translate reference

* umputun#10 use messages value for translate reference

* umputun#10 translate comment message

* umputun#10 translate comment form

* umputun#10 translate comment form

* umputun#10 translate root component

* umputun#10 translate user info

* umputun#10 translate settings

* umputun#10 translate settings

* umputun#10 translate toolbar

* umputun#10 translate errors messages

* umputun#10 sort dict

* umputun#10 fix after rebase

* umputun#10 localize anonymousLoginForm

* umputun#10 localize emailLoginForm

* umputun#10 update size-limit

* umputun#10 localize subscribe by rss

* umputun#10 localize subscribe by email

* umputun#10 add de locale

* umputun#10 increase limit size

* umputun#10 auto generate loadLocale

* umputun#10 add some documentation

* umputun#10 fix error messages

* fix typo

* don't bundle en locale

* fix message id

* change size limits

* Update extract message pattern

Co-Authored-By: Pavel Mineev <[email protected]>

Co-authored-by: Pavel Mineev <[email protected]>
  • Loading branch information
umputun and Pavel Mineev committed Mar 4, 2020
1 parent 5039e8d commit b22cd5f
Show file tree
Hide file tree
Showing 49 changed files with 2,642 additions and 466 deletions.
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ debug.test
*.test
remark42
/backend/var/
compose-private-backend.yml
compose-private-backend.yml
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,8 @@ Add this snippet to the bottom of web page:
// in well defined order
max_shown_comments: 10, // optional param; if it isn't defined default value (15) will be used
theme: 'dark', // optional param; if it isn't defined default value ('light') will be used
page_title: 'Moving to Remark42' // optional param; if it isn't defined `document.title` will be used
page_title: 'Moving to Remark42', // optional param; if it isn't defined `document.title` will be used
locale: 'en' // set up locale and language, if it isn't defined default value ('en') will be used
};
(function(c) {
Expand Down Expand Up @@ -469,6 +470,12 @@ Just call this function and pass a name of the theme that you want to turn on:
window.REMARK42.changeTheme('light');
```

##### Locales

Right now Remark has support three locales en, ru (partial translated), de(not translated).
You can pick one using configuration object.
Do you want support other locale? Please create [issue](https://github.com/umputun/remark42/issues).

#### Last comments

It's a widget which renders list of last comments from your site.
Expand Down
1 change: 1 addition & 0 deletions frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
.env
extracted-messages
4 changes: 2 additions & 2 deletions frontend/.size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ module.exports = [
limit: '2.55 KB',
},
{
limit: '68 KB',
limit: '83 KB',
path: 'public/remark.js',
},
{
limit: '30 KB',
limit: '45 KB',
path: 'public/last-comments.js',
},
{
Expand Down
41 changes: 24 additions & 17 deletions frontend/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,39 @@

### Code Style

* project uses typescript to statically analyze code
* project uses `eslint` to check frontend code. You can manually run via `npm run lint`.
* git hooks (via husky) installed automatically on `npm install` and check and try to fix code style if possible, otherwise commit will be rejected
* if you want IDE integration, you need `eslint` plugin to be installed.
- project uses typescript to statically analyze code
- project uses `eslint` to check frontend code. You can manually run via `npm run lint`.
- git hooks (via husky) installed automatically on `npm install` and check and try to fix code style if possible, otherwise commit will be rejected
- if you want IDE integration, you need `eslint` plugin to be installed.

### CSS Styles

* although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function.
* component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`.
* component base style resides in the component's root directory with name of component converted to kebab-case. For example `ListComments` style is located in `./app/components/list-comments/list-comments/scss`
* component's element style resides in its own subdirectory, with name consisting of full elements selector, for example `ListComments` `item` element is placed in `__item` directory under name `./list-comments__item.scss`
* each style should be `require`d in `index.ts` of component's root directory
- although styles have `scss` extension, it is actually pack of post-css plugins, so syntax differs, for example in `calc` function.
- component styles use BEM notation (at least it should): `block__element_modifier`. Also there are `mix` classes: `block_modifier`.
- component base style resides in the component's root directory with name of component converted to kebab-case. For example `ListComments` style is located in `./app/components/list-comments/list-comments/scss`
- component's element style resides in its own subdirectory, with name consisting of full elements selector, for example `ListComments` `item` element is placed in `__item` directory under name `./list-comments__item.scss`
- each style should be `require`d in `index.ts` of component's root directory

### Imports

* imports for typescript, javascript files should be without extension: `./index`, not `./index.ts`
* if file resides in same directory or in subdirectory import should be relative: `./types/something`
* otherwise it should start from `@app` namespace: `@app/common/store` which mapped to `/app/common/store.ts` in webpack, tsconfig and jest
- imports for typescript, javascript files should be without extension: `./index`, not `./index.ts`
- if file resides in same directory or in subdirectory import should be relative: `./types/something`
- otherwise it should start from `@app` namespace: `@app/common/store` which mapped to `/app/common/store.ts` in webpack, tsconfig and jest

### Testing

* project uses `jest` as test harness.
* jest check files that match regex `\.test\.(j|t)s(x?)$`, i.e `comment.test.tsx`, `comment.test.js`
* tests are running on push attempt
* example tests can be found in `./app/store/user/reducers.test.ts`, `./app/components/auth-panel/auth-panel.test.tsx`
- project uses `jest` as test harness.
- jest check files that match regex `\.test\.(j|t)s(x?)$`, i.e `comment.test.tsx`, `comment.test.js`
- tests are running on push attempt
- example tests can be found in `./app/store/user/reducers.test.ts`, `./app/components/auth-panel/auth-panel.test.tsx`

### how to add new locale.

- add new item to `./tasks/supportedLocales.json`
- run `npm run generate-langs`
- commit all changed files
- translate all string in new generated dictionary `./app/locale/<new-locale>.json`

### Notes

* Frontend part being bundled on docker env gets placed on `/src/web` and is available via `http://{host}/web`. for example `embed.js` entry point will be available at `http://{host}/web/embed.js`
- Frontend part being bundled on docker env gets placed on `/src/web` and is available via `http://{host}/web`. for example `embed.js` entry point will be available at `http://{host}/web/embed.js`
2 changes: 2 additions & 0 deletions frontend/app/common/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ export interface CommentsConfig {
theme?: Theme;
page_title?: string;
node?: string;
locale?: string;
}

export interface LastCommentsConfig {
host: string;
site_id: string;
max_last_comments: number;
locale?: string;
}
21 changes: 1 addition & 20 deletions frontend/app/common/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Sorting, AuthProvider, BlockingDuration, Theme } from './types';
import { Sorting, AuthProvider, Theme } from './types';
import * as configConstant from './constants.config';

export const BASE_URL = configConstant.BASE_URL;
Expand Down Expand Up @@ -33,25 +33,6 @@ export const LS_HIDDEN_USERS_KEY = '__remarkHiddenUsers';
/** cookie key under which sort preference resides */
export const COOKIE_SORT_KEY = 'remarkSort';

export const BLOCKING_DURATIONS: BlockingDuration[] = [
{
label: 'Permanently',
value: 'permanently',
},
{
label: 'For a month',
value: '43200m',
},
{
label: 'For a week',
value: '10080m',
},
{
label: 'For a day',
value: '1440m',
},
];

export const THEMES: Theme[] = ['light', 'dark'];

export const IS_MOBILE = /Android|webOS|iPhone|iPad|iPod|Opera Mini|Windows Phone/i.test(navigator.userAgent);
Expand Down
6 changes: 2 additions & 4 deletions frontend/app/common/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,8 @@ describe('fetcher', () => {
fail(data);
})
.catch(e => {
expect(e.code).toBe(-1);
expect(e.code).toBe(401);
expect(e.error).toBe('Not authorized.');
expect(e.details).toBe('Not authorized.');
});
});
it('should throw "Something went wrong." object on unknown status', async () => {
Expand All @@ -77,9 +76,8 @@ describe('fetcher', () => {
fail(data);
})
.catch(e => {
expect(e.code).toBe(-1);
expect(e.code).toBe(0);
expect(e.error).toBe('Something went wrong.');
expect(e.details).toBe('you given me something wrong');
});
});
});
Expand Down
78 changes: 43 additions & 35 deletions frontend/app/common/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { BASE_URL, API_BASE } from './constants';
import { siteId } from './settings';
import { StaticStore } from './static_store';
import { getCookie } from './cookies';
import { httpErrorMap } from '@app/utils/errorUtils';
import { httpErrorMap, isFailedFetch, httpMessages } from '@app/utils/errorUtils';

export type FetcherMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head';
const methods: FetcherMethod[] = ['get', 'post', 'put', 'patch', 'delete', 'head'];
Expand Down Expand Up @@ -73,46 +73,54 @@ const fetcher = methods.reduce<Partial<FetcherObject>>((acc, method) => {
rurl += (rurl.includes('?') ? '&' : '?') + `site=${siteId}`;
}

return fetch(rurl, parameters).then(res => {
const date = (res.headers.has('date') && res.headers.get('date')) || '';
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
const timeDiff = (new Date().getTime() - timestamp) / 1000;
StaticStore.serverClientTimeDiff = timeDiff;
return fetch(rurl, parameters)
.then(res => {
const date = (res.headers.has('date') && res.headers.get('date')) || '';
const timestamp = isNaN(Date.parse(date)) ? 0 : Date.parse(date);
const timeDiff = (new Date().getTime() - timestamp) / 1000;
StaticStore.serverClientTimeDiff = timeDiff;

if (res.status >= 400) {
if (httpErrorMap.has(res.status)) {
const errString = httpErrorMap.get(res.status)!;
throw {
code: -1,
error: errString,
details: errString,
};
}
return res.text().then(text => {
let err;
try {
err = JSON.parse(text);
} catch (e) {
if (logError) {
// eslint-disable-next-line no-console
console.error(err);
}
if (res.status >= 400) {
if (httpErrorMap.has(res.status)) {
const descriptor = httpErrorMap.get(res.status) || httpMessages.unexpectedError;
throw {
code: -1,
error: 'Something went wrong.',
details: text,
code: res.status,
error: descriptor.defaultMessage,
};
}
throw err;
});
}
return res.text().then(text => {
let err;
try {
err = JSON.parse(text);
} catch (e) {
if (logError) {
// eslint-disable-next-line no-console
console.error(err);
}
throw {
code: 0,
error: httpMessages.unexpectedError.defaultMessage,
};
}
throw err;
});
}

if (res.headers.has('Content-Type') && res.headers.get('Content-Type')!.indexOf('application/json') === 0) {
return res.json();
}
if (res.headers.has('Content-Type') && res.headers.get('Content-Type')!.indexOf('application/json') === 0) {
return res.json();
}

return res.text();
});
return res.text();
})
.catch(e => {
if (isFailedFetch(e)) {
throw {
code: -2,
error: e.message,
};
}
throw e;
});
};
return acc;
}, {}) as FetcherObject;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** @jsx createElement */
import { createElement, Component, createRef } from 'preact';
import b from 'bem-react-helper';
import { IntlShape, defineMessages, FormattedMessage } from 'react-intl';
import { Theme } from '@app/common/types';

import { Input } from '@app/components/input';
Expand All @@ -10,13 +11,30 @@ interface Props {
onSubmit(username: string): Promise<void>;
theme: Theme;
className?: string;
intl: IntlShape;
}

interface State {
inputValue: string;
honeyPotValue: boolean;
}

export const messages = defineMessages({
lengthLimit: {
id: 'anonymousLoginForm.length-limit',
defaultMessage: 'Username must be at least 3 characters long',
},
symbolLimit: {
id: 'anonymousLoginForm.symbol-limit',
defaultMessage:
'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces',
},
userName: {
id: 'anonymousLoginForm.user-name',
defaultMessage: 'Username',
},
});

export class AnonymousLoginForm extends Component<Props, State> {
static usernameRegex = /^[a-zA-Z][\w ]+$/;

Expand Down Expand Up @@ -51,9 +69,9 @@ export class AnonymousLoginForm extends Component<Props, State> {

getUsernameInvalidReason(): string | null {
const value = this.state.inputValue;
if (value.length < 3) return 'Username must be at least 3 characters long';
if (!AnonymousLoginForm.usernameRegex.test(value))
return 'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces';
const intl = this.props.intl;
if (value.length < 3) return intl.formatMessage(messages.lengthLimit);
if (!AnonymousLoginForm.usernameRegex.test(value)) return intl.formatMessage(messages.symbolLimit);
return null;
}

Expand All @@ -69,6 +87,7 @@ export class AnonymousLoginForm extends Component<Props, State> {

render() {
const props = this.props;
const intl = props.intl;
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-panel-anonymous-login-form', {}, { theme: props.theme });
if (props.className) {
Expand All @@ -82,7 +101,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
<Input
ref={this.inputRef}
mix="auth-panel-anonymous-login-form__input"
placeholder="Username"
placeholder={intl.formatMessage(messages.userName)}
value={this.state.inputValue}
onInput={this.onChange}
/>
Expand All @@ -103,7 +122,7 @@ export class AnonymousLoginForm extends Component<Props, State> {
title={usernameInvalidReason || ''}
disabled={usernameInvalidReason !== null}
>
Log in
<FormattedMessage id="anonymousLoginForm.log-in" defaultMessage="Log in" />
</Button>
</form>
);
Expand Down
Loading

0 comments on commit b22cd5f

Please sign in to comment.