mirror of
https://github.com/cupcakearmy/svelte-i18n.git
synced 2024-11-16 09:59:58 +01:00
Support svelte v3 (#6)
This commit is contained in:
parent
cec700af53
commit
0ae0cc8148
@ -3,4 +3,5 @@
|
||||
/yarn.lock
|
||||
/package-lock.json
|
||||
/src
|
||||
/coverage
|
||||
/coverage
|
||||
/example
|
280
README.md
280
README.md
@ -4,132 +4,224 @@
|
||||
|
||||
## Usage
|
||||
|
||||
### On the `store`
|
||||
`svelte-i18n` utilizes svelte `stores` for keeping track of the current locale, dictionary of messages and the main format function. This way, we keep everything neat, in sync and easy to use on your svelte files.
|
||||
|
||||
---
|
||||
|
||||
### Locale
|
||||
|
||||
The `locale` store defines what is the current locale.
|
||||
|
||||
```js
|
||||
import { i18n } from 'svelte-i18n'
|
||||
import { Store } from 'svelte/store'
|
||||
import { locale, dictionary } from 'svelte-i18n'
|
||||
|
||||
/** i18n(svelteStore, { dictionary }) */
|
||||
let store = new Store()
|
||||
// Set the current locale to en-US
|
||||
locale.set('en-US')
|
||||
|
||||
store = i18n(store, {
|
||||
dictionary: {
|
||||
'pt-BR': {
|
||||
message: 'Mensagem',
|
||||
greeting: 'Olá {name}, como vai?',
|
||||
greetingIndex: 'Olá {0}, como vai?',
|
||||
meter: 'metros | metro | metros',
|
||||
book: 'livro | livros',
|
||||
messages: {
|
||||
alert: 'Alerta',
|
||||
error: 'Erro',
|
||||
},
|
||||
// This is a store, so we can subscribe to its changes
|
||||
locale.subscribe(() => {
|
||||
console.log('locale change')
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### The dictionary
|
||||
|
||||
The `dictionary` store defines the dictionary of messages of all locales.
|
||||
|
||||
```js
|
||||
import { locale, dictionary } from 'svelte-i18n'
|
||||
|
||||
// Define a locale dictionary
|
||||
dictionary.set({
|
||||
pt: {
|
||||
message: 'Mensagem',
|
||||
'switch.lang': 'Trocar idioma',
|
||||
greeting: {
|
||||
ask: 'Por favor, digite seu nome',
|
||||
message: 'Olá {name}, como vai?',
|
||||
},
|
||||
'en-US': {
|
||||
message: 'Message',
|
||||
greeting: 'Hello {name}, how are you?',
|
||||
greetingIndex: 'Hello {0}, how are you?',
|
||||
meter: 'meters | meter | meters',
|
||||
book: 'book | books',
|
||||
messages: {
|
||||
alert: 'Alert',
|
||||
error: 'Error',
|
||||
},
|
||||
photos:
|
||||
'Você {n, plural, =0 {não tem fotos.} =1 {tem uma foto.} other {tem # fotos.}}',
|
||||
cats: 'Tenho {n, number} {n,plural,=0{gatos}one{gato}other{gatos}}',
|
||||
},
|
||||
en: {
|
||||
message: 'Message',
|
||||
'switch.lang': 'Switch language',
|
||||
greeting: {
|
||||
ask: 'Please type your name',
|
||||
message: 'Hello {name}, how are you?',
|
||||
},
|
||||
photos:
|
||||
'You have {n, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}',
|
||||
cats: 'I have {n, number} {n,plural,one{cat}other{cats}}',
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* Extend the initial dictionary.
|
||||
* Dictionaries are deeply merged.
|
||||
* */
|
||||
store.i18n.extendDictionary({
|
||||
'pt-BR': {
|
||||
messages: {
|
||||
warn: 'Aviso',
|
||||
success: 'Sucesso',
|
||||
},
|
||||
},
|
||||
'en-US': {
|
||||
messages: {
|
||||
warn: 'Warn',
|
||||
success: 'Success',
|
||||
},
|
||||
},
|
||||
// It's also possible to merge the current dictionary
|
||||
// with other objets
|
||||
dictionary.update(dict => {
|
||||
dict.fr = {
|
||||
// ...french messages
|
||||
}
|
||||
return dict
|
||||
})
|
||||
|
||||
/** Set the initial locale */
|
||||
store.i18n.setLocale('en-US')
|
||||
```
|
||||
|
||||
### On `templates`
|
||||
Each language message dictionary can be as deep as you want. Messages can also be looked up by a string represetation of it's path on the dictionary (i.e `greeting.message`).
|
||||
|
||||
#### Basic usage
|
||||
---
|
||||
|
||||
### Formatting
|
||||
|
||||
The `_`/`format` store is the actual formatter method. To use it, it's simple as any other svelte store.
|
||||
|
||||
```html
|
||||
<script>
|
||||
// locale is en
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
<input placeholder="{$_('greeting.ask')}" />
|
||||
```
|
||||
|
||||
`svelte-i18n` uses `formatjs` behind the scenes, which means it supports the [ICU message format](http://userguide.icu-project.org/formatparse/messages) for interpolation, pluralization and much more.
|
||||
|
||||
```html
|
||||
<div>
|
||||
{$_('message')}: {$_('messages.success')}
|
||||
<!-- Message: SUCCESS-->
|
||||
{$_('greeting.message', { name: 'John' })}
|
||||
<!-- Hello John, how are you? -->
|
||||
|
||||
{$_('photos', { n: 0 })}
|
||||
<!-- You have no photos. -->
|
||||
|
||||
{$_('photos', { n: 12 })}
|
||||
<!-- You have 12 photos. -->
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Current locale
|
||||
### Formatting methods
|
||||
|
||||
The current locale is available via `this.store.get().locale`.
|
||||
#### `_` / `format`
|
||||
|
||||
#### Interpolation
|
||||
`function(messageId: string, locale:? string): string`
|
||||
|
||||
`function(messageId: string, interpolations?: object, locale:? string): string`
|
||||
|
||||
Main formatting method that formats a localized message by its id.
|
||||
|
||||
```html
|
||||
<div>
|
||||
<!-- Named interpolation -->
|
||||
{$_('greeting', { name: 'John' })}
|
||||
<!-- Hello John, how are you?-->
|
||||
<script>
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
<!-- List interpolation -->
|
||||
{$_('greetingIndex', ['John'])}
|
||||
<!-- Hello John, how are you?-->
|
||||
</div>
|
||||
<div>{$_('greeting.ask')}</div>
|
||||
<!-- Please type your name -->
|
||||
```
|
||||
|
||||
#### Pluralization
|
||||
#### `_.upper`
|
||||
|
||||
Transforms a localized message into uppercase.
|
||||
|
||||
```html
|
||||
<div>
|
||||
0 {$_.plural('meter', 0)}
|
||||
<!-- 0 meters -->
|
||||
<script>
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
1 {$_.plural('meter', 1)}
|
||||
<!-- 1 meter -->
|
||||
|
||||
100 {$_.plural('meter', 100)}
|
||||
<!-- 100 meters -->
|
||||
|
||||
0 {$_.plural('book', 0)}
|
||||
<!-- 0 books -->
|
||||
|
||||
1 {$_.plural('book', 1)}
|
||||
<!-- 1 book -->
|
||||
|
||||
10 {$_.plural('book', 10)}
|
||||
<!-- 10 books -->
|
||||
</div>
|
||||
<div>{$_.upper('greeting.ask')}</div>
|
||||
<!-- PLEASE TYPE YOUR NAME -->
|
||||
```
|
||||
|
||||
#### Utilities
|
||||
#### `_.lower`
|
||||
|
||||
Transforms a localized message into lowercase.
|
||||
|
||||
```html
|
||||
<div>
|
||||
{$_.upper('message')}
|
||||
<!-- MESSAGE -->
|
||||
<script>
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
{$_.lower('message')}
|
||||
<!-- message -->
|
||||
|
||||
{$_.capital('message')}
|
||||
<!-- Message -->
|
||||
|
||||
{$_.title('greeting', { name: 'John' })}
|
||||
<!-- Hello John, How Are You?-->
|
||||
</div>
|
||||
<div>{$_.lower('greeting.ask')}</div>
|
||||
<!-- PLEASE TYPE YOUR NAME -->
|
||||
```
|
||||
|
||||
#### `_.capital`
|
||||
|
||||
Capitalize a localized message.
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
<div>{$_.capital('greeting.ask')}</div>
|
||||
<!-- Please type your name -->
|
||||
```
|
||||
|
||||
#### `_.title`
|
||||
|
||||
Transform the message into title case.
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
<div>{$_.capital('greeting.ask')}</div>
|
||||
<!-- Please Type Your Name -->
|
||||
```
|
||||
|
||||
#### `_.time`
|
||||
|
||||
`function(time: Date, format?: string, locale?: string)`
|
||||
|
||||
Formats a date object into a time string with the specified format (`short`, `medium`, `long`, `full`). Please refer to the [ICU message format](http://userguide.icu-project.org/formatparse/messages) documentation for all available. formats
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
<div>{$_.time(new Date(2019, 3, 24, 23, 45))}</div>
|
||||
<!-- 11:45 PM -->
|
||||
|
||||
<div>{$_.time(new Date(2019, 3, 24, 23, 45), 'medium')}</div>
|
||||
<!-- 11:45:00 PM -->
|
||||
```
|
||||
|
||||
#### `_.date`
|
||||
|
||||
`function(date: Date, format?: string, locale?: string)`
|
||||
|
||||
Formats a date object into a string with the specified format (`short`, `medium`, `long`, `full`). Please refer to the [ICU message format](http://userguide.icu-project.org/formatparse/messages) documentation for all available. formats
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
<div>{$_.date(new Date(2019, 3, 24, 23, 45))}</div>
|
||||
<!-- 4/24/19 -->
|
||||
|
||||
<div>{$_.date(new Date(2019, 3, 24, 23, 45), 'medium')}</div>
|
||||
<!-- Apr 24, 2019 -->
|
||||
```
|
||||
|
||||
#### `_.number`
|
||||
|
||||
`function(number: Number, locale?: string)`
|
||||
|
||||
Formats a number with the specified locale
|
||||
|
||||
```html
|
||||
<script>
|
||||
import { _ } from 'svelte-i18n'
|
||||
</script>
|
||||
|
||||
<div>{$_.number(100000000)}</div>
|
||||
<!-- 100,000,000 -->
|
||||
|
||||
<div>{$_.number(100000000, 'pt')}</div>
|
||||
<!-- 100.000.000 -->
|
||||
```
|
||||
|
3
example/.gitignore
vendored
Normal file
3
example/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
public/bundle.*
|
68
example/README.md
Normal file
68
example/README.md
Normal file
@ -0,0 +1,68 @@
|
||||
*Psst — looking for a shareable component template? Go here --> [sveltejs/component-template](https://github.com/sveltejs/component-template)*
|
||||
|
||||
---
|
||||
|
||||
# svelte app
|
||||
|
||||
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template.
|
||||
|
||||
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):
|
||||
|
||||
```bash
|
||||
npx degit sveltejs/template svelte-app
|
||||
cd svelte-app
|
||||
```
|
||||
|
||||
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
|
||||
|
||||
|
||||
## Get started
|
||||
|
||||
Install the dependencies...
|
||||
|
||||
```bash
|
||||
cd svelte-app
|
||||
npm install
|
||||
```
|
||||
|
||||
...then start [Rollup](https://rollupjs.org):
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Navigate to [localhost:5000](http://localhost:5000). You should see your app running. Edit a component file in `src`, save it, and reload the page to see your changes.
|
||||
|
||||
|
||||
## Deploying to the web
|
||||
|
||||
### With [now](https://zeit.co/now)
|
||||
|
||||
Install `now` if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g now
|
||||
```
|
||||
|
||||
Then, from within your project folder:
|
||||
|
||||
```bash
|
||||
now
|
||||
```
|
||||
|
||||
As an alternative, use the [Now desktop client](https://zeit.co/download) and simply drag the unzipped project folder to the taskbar icon.
|
||||
|
||||
### With [surge](https://surge.sh/)
|
||||
|
||||
Install `surge` if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install -g surge
|
||||
```
|
||||
|
||||
Then, from within your project folder:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
surge public
|
||||
```
|
2924
example/package-lock.json
generated
Normal file
2924
example/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
example/package.json
Normal file
25
example/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "svelte-app",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"autobuild": "rollup -c -w",
|
||||
"dev": "run-p start:dev autobuild",
|
||||
"start": "sirv public",
|
||||
"start:dev": "sirv public --dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rollup": "^1.10.1",
|
||||
"rollup-plugin-commonjs": "^9.3.4",
|
||||
"rollup-plugin-livereload": "^1.0.0",
|
||||
"rollup-plugin-node-resolve": "^4.2.3",
|
||||
"rollup-plugin-svelte": "^5.0.3",
|
||||
"rollup-plugin-terser": "^4.0.4",
|
||||
"sirv-cli": "^0.4.0",
|
||||
"svelte": "^3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"svelte-i18n": "^1.0.2-beta"
|
||||
}
|
||||
}
|
BIN
example/public/favicon.png
Normal file
BIN
example/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
61
example/public/global.css
Normal file
61
example/public/global.css
Normal file
@ -0,0 +1,61 @@
|
||||
html, body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(0,100,200);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: rgb(0,80,160);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input, button, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0.4em;
|
||||
margin: 0 0 0.5em 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f4f4f4;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
border-color: #666;
|
||||
}
|
21
example/public/index.html
Normal file
21
example/public/index.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
||||
<title>Svelte app</title>
|
||||
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/tachyons/4.11.1/tachyons.css"
|
||||
/>
|
||||
<link rel="stylesheet" href="global.css" />
|
||||
<link rel="stylesheet" href="bundle.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script src="bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
44
example/rollup.config.js
Normal file
44
example/rollup.config.js
Normal file
@ -0,0 +1,44 @@
|
||||
import svelte from 'rollup-plugin-svelte'
|
||||
import resolve from 'rollup-plugin-node-resolve'
|
||||
import commonjs from 'rollup-plugin-commonjs'
|
||||
import livereload from 'rollup-plugin-livereload'
|
||||
import { terser } from 'rollup-plugin-terser'
|
||||
|
||||
const production = !process.env.ROLLUP_WATCH
|
||||
|
||||
export default {
|
||||
input: 'src/main.js',
|
||||
output: {
|
||||
sourcemap: true,
|
||||
format: 'iife',
|
||||
name: 'app',
|
||||
file: 'public/bundle.js',
|
||||
},
|
||||
plugins: [
|
||||
svelte({
|
||||
// enable run-time checks when not in production
|
||||
dev: !production,
|
||||
// we'll extract any component CSS out into
|
||||
// a separate file — better for performance
|
||||
css: css => {
|
||||
css.write('public/bundle.css')
|
||||
},
|
||||
}),
|
||||
|
||||
// If you have external dependencies installed from
|
||||
// npm, you'll most likely need these plugins. In
|
||||
// some cases you'll need additional configuration —
|
||||
// consult the documentation for details:
|
||||
// https://github.com/rollup/rollup-plugin-commonjs
|
||||
resolve(),
|
||||
commonjs(),
|
||||
|
||||
// Watch the `public` directory and refresh the
|
||||
// browser on changes when not in production
|
||||
!production && livereload('public'),
|
||||
|
||||
// If we're building for production (npm run build
|
||||
// instead of npm run dev), minify
|
||||
production && terser(),
|
||||
],
|
||||
}
|
45
example/src/App.svelte
Normal file
45
example/src/App.svelte
Normal file
@ -0,0 +1,45 @@
|
||||
<script>
|
||||
import { locale, _ } from 'svelte-i18n'
|
||||
|
||||
let name = ''
|
||||
let pluralN = 2
|
||||
let catsN = 992301
|
||||
let date = new Date()
|
||||
|
||||
$: oppositeLocale = $locale === 'pt' ? 'en' : 'pt'
|
||||
|
||||
setInterval(() => {
|
||||
date = new Date()
|
||||
}, 1000)
|
||||
</script>
|
||||
|
||||
<input
|
||||
class="w-100"
|
||||
type="text"
|
||||
placeholder={$_('greeting.ask')}
|
||||
bind:value={name} />
|
||||
<br />
|
||||
|
||||
<h1>{$_.title('greeting.message', { name })}</h1>
|
||||
|
||||
<br />
|
||||
<input type="range" min="0" max="5" step="1" bind:value={pluralN} />
|
||||
<h2>Plural: {$_('photos', { n: pluralN })}</h2>
|
||||
|
||||
<br />
|
||||
<input type="range" min="100" max="100000000" step="10000" bind:value={catsN} />
|
||||
<h2>Number: {$_('cats', { n: catsN })}</h2>
|
||||
|
||||
<br />
|
||||
<h2>Number util: {$_.number(catsN)}</h2>
|
||||
|
||||
<br />
|
||||
<h2>Date util: {$_.date(date, 'short')}</h2>
|
||||
|
||||
<br />
|
||||
<h2>Time util: {$_.time(date, 'medium')}</h2>
|
||||
|
||||
<br />
|
||||
<button on:click={() => locale.set(oppositeLocale)}>
|
||||
{$_('switch.lang', null, oppositeLocale)}
|
||||
</button>
|
31
example/src/i18n.js
Normal file
31
example/src/i18n.js
Normal file
@ -0,0 +1,31 @@
|
||||
import { locale, dictionary } from 'svelte-i18n'
|
||||
|
||||
// setting the locale
|
||||
locale.set('pt')
|
||||
|
||||
// subscribe to locale changes
|
||||
locale.subscribe(() => {
|
||||
console.log('locale change')
|
||||
})
|
||||
|
||||
// defining a locale dictionary
|
||||
dictionary.set({
|
||||
pt: {
|
||||
'switch.lang': 'Trocar idioma',
|
||||
greeting: {
|
||||
ask: 'Por favor, digite seu nome',
|
||||
message: 'Olá {name}, como vai?',
|
||||
},
|
||||
photos: 'Você {n, plural, =0 {não tem fotos.} =1 {tem uma foto.} other {tem # fotos.}}',
|
||||
cats: 'Tenho {n, number} {n,plural,=0{gatos}one{gato}other{gatos}}',
|
||||
},
|
||||
en: {
|
||||
'switch.lang': 'Switch language',
|
||||
greeting: {
|
||||
ask: 'Please type your name',
|
||||
message: 'Hello {name}, how are you?',
|
||||
},
|
||||
photos: 'You have {n, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}',
|
||||
cats: 'I have {n, number} {n,plural,one{cat}other{cats}}'
|
||||
},
|
||||
})
|
9
example/src/main.js
Normal file
9
example/src/main.js
Normal file
@ -0,0 +1,9 @@
|
||||
import './i18n.js'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.body,
|
||||
props: { name: 'world' },
|
||||
})
|
||||
|
||||
export default app
|
12517
package-lock.json
generated
12517
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
45
package.json
45
package.json
@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "svelte-i18n",
|
||||
"version": "0.0.5",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"main": "dist/i18n.js",
|
||||
"module": "dist/i18n.m.js",
|
||||
"module": "dist/i18n.mjs",
|
||||
"types": "src/index.d.ts",
|
||||
"description": "Internationalization library for Svelte",
|
||||
"author": "Christian Kaisermann <christian@kaisermann.me>",
|
||||
@ -16,13 +16,12 @@
|
||||
"translation"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "microbundle --format=cjs,es",
|
||||
"build": "microbundle --format=cjs,es --no-sourcemap",
|
||||
"start": "microbundle watch --format=cjs,es",
|
||||
"test": "jest --no-cache --verbose",
|
||||
"test:watch": "jest --no-cache --verbose --watchAll",
|
||||
"lint": "eslint \"src/**/*.js\"",
|
||||
"format": "prettier --loglevel silent --write \"src/**/*.js\" && eslint --fix \"src/**/*.js\"",
|
||||
"prepublishOnly": "npm run format && npm run test && npm run build"
|
||||
"format": "prettier --loglevel silent --write \"src/**/*.js\" && eslint --fix \"src/**/*.js\""
|
||||
},
|
||||
"jest": {
|
||||
"verbose": true,
|
||||
@ -50,28 +49,30 @@
|
||||
"collectCoverage": true
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0-beta.56",
|
||||
"@babel/preset-env": "^7.0.0-beta.56",
|
||||
"@babel/core": "^7.4.4",
|
||||
"@babel/preset-env": "^7.4.4",
|
||||
"babel-core": "^7.0.0-bridge.0",
|
||||
"babel-jest": "^23.4.2",
|
||||
"eslint": "^5.3.0",
|
||||
"eslint-config-prettier": "^2.9.0",
|
||||
"eslint-config-standard": "^11.0.0",
|
||||
"eslint-plugin-import": "^2.13.0",
|
||||
"eslint-plugin-node": "^7.0.1",
|
||||
"eslint-plugin-prettier": "^2.6.2",
|
||||
"eslint-plugin-promise": "^3.8.0",
|
||||
"eslint-plugin-standard": "^3.1.0",
|
||||
"jest": "^23.4.2",
|
||||
"microbundle": "^0.6.0",
|
||||
"prettier": "^1.14.1",
|
||||
"svelte": "^2.9.11"
|
||||
"babel-jest": "^24.8.0",
|
||||
"eslint": "^5.16.0",
|
||||
"eslint-config-prettier": "^4.3.0",
|
||||
"eslint-config-standard": "^12.0.0",
|
||||
"eslint-plugin-import": "^2.17.2",
|
||||
"eslint-plugin-node": "^9.0.1",
|
||||
"eslint-plugin-prettier": "^3.1.0",
|
||||
"eslint-plugin-promise": "^4.1.1",
|
||||
"eslint-plugin-standard": "^4.0.0",
|
||||
"jest": "^24.8.0",
|
||||
"microbundle": "^0.11.0",
|
||||
"prettier": "^1.17.1",
|
||||
"svelte": "^3.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^2.9.11"
|
||||
"svelte": "^3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"deepmerge": "^2.1.1",
|
||||
"deepmerge": "^3.2.0",
|
||||
"intl-messageformat": "^2.2.0",
|
||||
"micro-memoize": "^3.0.1",
|
||||
"object-resolve-path": "^1.1.1"
|
||||
}
|
||||
}
|
||||
|
124
src/formatter.js
124
src/formatter.js
@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Adapted from 'https://github.com/kazupon/vue-i18n/blob/dev/src/format.js'
|
||||
* Copyright (c) 2016 kazuya kawaguchi
|
||||
**/
|
||||
import { isObject } from './utils'
|
||||
|
||||
const RE_TOKEN_LIST_VALUE = /^(\d)+/
|
||||
const RE_TOKEN_NAMED_VALUE = /^(\w)+/
|
||||
|
||||
export default class Formatter {
|
||||
constructor() {
|
||||
this._caches = Object.create(null)
|
||||
}
|
||||
|
||||
interpolate(message, values) {
|
||||
if (!values) {
|
||||
return [message]
|
||||
}
|
||||
|
||||
let tokens = this._caches[message]
|
||||
if (!tokens) {
|
||||
tokens = parse(message)
|
||||
this._caches[message] = tokens
|
||||
}
|
||||
|
||||
return compile(tokens, values)
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a identification string into cached Tokens */
|
||||
export function parse(format) {
|
||||
const tokens = []
|
||||
let position = 0
|
||||
let currentText = ''
|
||||
|
||||
while (position < format.length) {
|
||||
let char = format[position++]
|
||||
|
||||
/** If found any character that's not a '{' (does not include '\{'), assume text */
|
||||
if (char !== '{' || (position > 0 && char[position - 1] === '\\')) {
|
||||
currentText += char
|
||||
} else {
|
||||
/** Beginning of a interpolation */
|
||||
if (currentText.length) {
|
||||
tokens.push({ type: 'text', value: currentText })
|
||||
}
|
||||
|
||||
/** Reset the current text string because we're dealing interpolation entry */
|
||||
currentText = ''
|
||||
|
||||
/** Key name */
|
||||
let namedKey = ''
|
||||
char = format[position++]
|
||||
|
||||
while (char !== '}') {
|
||||
namedKey += char
|
||||
char = format[position++]
|
||||
}
|
||||
|
||||
const type = RE_TOKEN_LIST_VALUE.test(namedKey)
|
||||
? 'list'
|
||||
: RE_TOKEN_NAMED_VALUE.test(namedKey)
|
||||
? 'named'
|
||||
: 'unknown'
|
||||
|
||||
tokens.push({ value: namedKey, type })
|
||||
}
|
||||
}
|
||||
|
||||
/** If there's any text left, push it to the tokens list */
|
||||
if (currentText) {
|
||||
tokens.push({ type: 'text', value: currentText })
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
export function compile(tokens, values) {
|
||||
const compiled = []
|
||||
let index = 0
|
||||
|
||||
const mode = Array.isArray(values)
|
||||
? 'list'
|
||||
: isObject(values)
|
||||
? 'named'
|
||||
: 'unknown'
|
||||
|
||||
if (mode === 'unknown') {
|
||||
return compiled
|
||||
}
|
||||
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index++]
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
compiled.push(token.value)
|
||||
break
|
||||
case 'list':
|
||||
compiled.push(values[parseInt(token.value, 10)])
|
||||
break
|
||||
case 'named':
|
||||
if (mode === 'named') {
|
||||
compiled.push(values[token.value])
|
||||
} else {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(
|
||||
`[svelte-i18n] Type of token '${
|
||||
token.type
|
||||
}' and format of value '${mode}' don't match!`,
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'unknown':
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`[svelte-i18n] Detect 'unknown' type of token!`)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return compiled
|
||||
}
|
159
src/index.js
159
src/index.js
@ -1,100 +1,71 @@
|
||||
import deepmerge from 'deepmerge'
|
||||
import { writable, derived } from 'svelte/store'
|
||||
import resolvePath from 'object-resolve-path'
|
||||
import { capital, title, upper, lower } from './utils'
|
||||
import Formatter from './formatter'
|
||||
import IntlMessageFormat from 'intl-messageformat'
|
||||
import memoize from 'micro-memoize'
|
||||
import { capital, title, upper, lower } from './utils.js'
|
||||
|
||||
export { capital, title, upper, lower }
|
||||
let currentLocale
|
||||
let currentDictionary
|
||||
|
||||
export function i18n(store, { dictionary: initialDictionary }) {
|
||||
const formatter = new Formatter()
|
||||
let currentLocale
|
||||
let dictionary = Array.isArray(initialDictionary)
|
||||
? deepmerge.all(initialDictionary)
|
||||
: initialDictionary
|
||||
const getMessageFormatter = memoize(
|
||||
(message, locale, formats) => new IntlMessageFormat(message, locale, formats),
|
||||
)
|
||||
|
||||
const getLocalizedMessage = (
|
||||
path,
|
||||
interpolations,
|
||||
locale = currentLocale,
|
||||
transformers = undefined,
|
||||
) => {
|
||||
let message = resolvePath(dictionary[locale], path)
|
||||
const lookupMessage = memoize((path, locale) => {
|
||||
return (
|
||||
currentDictionary[locale][path] ||
|
||||
resolvePath(currentDictionary[locale], path)
|
||||
)
|
||||
})
|
||||
|
||||
if (!message) return path
|
||||
|
||||
if (transformers) {
|
||||
for (let i = 0, len = transformers.length; i < len; i++) {
|
||||
message = transformers[i](message)
|
||||
}
|
||||
}
|
||||
|
||||
if (interpolations) {
|
||||
message = formatter.interpolate(message, interpolations).join('')
|
||||
}
|
||||
|
||||
return message.trim()
|
||||
}
|
||||
|
||||
const utilities = {
|
||||
capital(path, interpolations, locale) {
|
||||
return capital(getLocalizedMessage(path, interpolations, locale))
|
||||
},
|
||||
title(path, interpolations, locale) {
|
||||
return title(getLocalizedMessage(path, interpolations, locale))
|
||||
},
|
||||
upper(path, interpolations, locale) {
|
||||
return upper(getLocalizedMessage(path, interpolations, locale))
|
||||
},
|
||||
lower(path, interpolations, locale) {
|
||||
return lower(getLocalizedMessage(path, interpolations, locale))
|
||||
},
|
||||
plural(path, counter, interpolations, locale) {
|
||||
return getLocalizedMessage(path, interpolations, locale, [
|
||||
message => {
|
||||
const parts = message.split('|')
|
||||
|
||||
/** Check for 'singular|plural' or 'zero|one|multiple' pluralization */
|
||||
const isSimplePluralization = parts.length === 2
|
||||
let choice = isSimplePluralization ? 1 : 0
|
||||
|
||||
if (typeof counter === 'number') {
|
||||
choice = Math.min(
|
||||
Math.abs(counter) - (isSimplePluralization ? 1 : 0),
|
||||
parts.length - 1,
|
||||
)
|
||||
}
|
||||
|
||||
return parts[choice]
|
||||
},
|
||||
])
|
||||
},
|
||||
}
|
||||
|
||||
store.on('locale', newLocale => {
|
||||
if (!Object.keys(dictionary).includes(newLocale)) {
|
||||
console.error(`[svelte-i18n] Couldn't find the "${newLocale}" locale.`)
|
||||
return
|
||||
}
|
||||
currentLocale = newLocale
|
||||
const _ = getLocalizedMessage
|
||||
|
||||
_.upper = utilities.upper
|
||||
_.lower = utilities.lower
|
||||
_.title = utilities.title
|
||||
_.capital = utilities.capital
|
||||
_.plural = utilities.plural
|
||||
|
||||
store.set({ locale: newLocale, _ })
|
||||
})
|
||||
|
||||
store.i18n = {
|
||||
setLocale(locale) {
|
||||
store.fire('locale', locale)
|
||||
},
|
||||
extendDictionary(...list) {
|
||||
dictionary = deepmerge.all([dictionary, ...list])
|
||||
},
|
||||
}
|
||||
|
||||
return store
|
||||
const formatMessage = (message, interpolations, locale = currentLocale) => {
|
||||
return getMessageFormatter(message, locale).format(interpolations)
|
||||
}
|
||||
|
||||
const getLocalizedMessage = (path, interpolations, locale = currentLocale) => {
|
||||
if (typeof interpolations === 'string') {
|
||||
locale = interpolations
|
||||
interpolations = undefined
|
||||
}
|
||||
const message = lookupMessage(path, locale)
|
||||
|
||||
if (!message) return path
|
||||
if (!interpolations) return message
|
||||
|
||||
return getMessageFormatter(message, locale).format(interpolations)
|
||||
}
|
||||
|
||||
getLocalizedMessage.time = (t, format = 'short', locale) =>
|
||||
formatMessage(`{t,time,${format}}`, { t }, locale)
|
||||
|
||||
getLocalizedMessage.date = (d, format = 'short', locale) =>
|
||||
formatMessage(`{d,date,${format}}`, { d }, locale)
|
||||
|
||||
getLocalizedMessage.number = (n, locale) =>
|
||||
formatMessage('{n,number}', { n }, locale)
|
||||
|
||||
getLocalizedMessage.capital = (path, interpolations, locale) =>
|
||||
capital(getLocalizedMessage(path, interpolations, locale))
|
||||
|
||||
getLocalizedMessage.title = (path, interpolations, locale) =>
|
||||
title(getLocalizedMessage(path, interpolations, locale))
|
||||
|
||||
getLocalizedMessage.upper = (path, interpolations, locale) =>
|
||||
upper(getLocalizedMessage(path, interpolations, locale))
|
||||
|
||||
getLocalizedMessage.lower = (path, interpolations, locale) =>
|
||||
lower(getLocalizedMessage(path, interpolations, locale))
|
||||
|
||||
const dictionary = writable({})
|
||||
dictionary.subscribe(newDictionary => {
|
||||
currentDictionary = newDictionary
|
||||
})
|
||||
|
||||
const locale = writable({})
|
||||
locale.subscribe(newLocale => {
|
||||
currentLocale = newLocale
|
||||
})
|
||||
|
||||
const format = derived(locale, () => getLocalizedMessage)
|
||||
|
||||
export { locale, format as _, format, dictionary }
|
||||
|
@ -1,6 +1,6 @@
|
||||
|
||||
|
||||
export const capital = str => str.replace(/(^|\s)\S/, l => l.toUpperCase())
|
||||
export const title = str => str.replace(/(^|\s)\S/g, l => l.toUpperCase())
|
||||
export const upper = str => str.toLocaleUpperCase()
|
||||
export const lower = str => str.toLocaleLowerCase()
|
||||
|
||||
export const isObject = obj => obj !== null && typeof obj === 'object'
|
||||
export const lower = str => str.toLocaleLowerCase()
|
@ -1,182 +1,110 @@
|
||||
// TODO: A more serious test
|
||||
import { dictionary, locale, format } from '../src/index'
|
||||
import { capital, title, upper, lower } from '../src/utils'
|
||||
|
||||
import { i18n } from '../src/index'
|
||||
import { Store } from 'svelte/store.umd'
|
||||
import { capital, title, upper, lower, isObject } from '../src/utils'
|
||||
let _
|
||||
let currentLocale
|
||||
|
||||
let store = new Store()
|
||||
const locales = {
|
||||
'pt-br': {
|
||||
test: 'teste',
|
||||
phrase: 'adoro banana',
|
||||
phrases: ['Frase 1', 'Frase 2'],
|
||||
pluralization: 'Zero | Um | Muito!',
|
||||
simplePluralization: 'Singular | Plural',
|
||||
interpolation: {
|
||||
key: 'Olá, {0}! Como está {1}?',
|
||||
named: 'Olá, {name}! Como está {time}?',
|
||||
const dict = {
|
||||
pt: {
|
||||
hi: 'olá você',
|
||||
'switch.lang': 'Trocar idioma',
|
||||
greeting: {
|
||||
ask: 'Por favor, digite seu nome',
|
||||
message: 'Olá {name}, como vai?',
|
||||
},
|
||||
interpolationPluralization: 'One thingie | {0} thingies',
|
||||
wow: {
|
||||
much: {
|
||||
deep: {
|
||||
list: ['Muito', 'muito profundo'],
|
||||
},
|
||||
},
|
||||
},
|
||||
obj: {
|
||||
a: 'a',
|
||||
b: 'b',
|
||||
photos:
|
||||
'Você {n, plural, =0 {não tem fotos.} =1 {tem uma foto.} other {tem # fotos.}}',
|
||||
cats: 'Tenho {n, number} {n,plural,=0{gatos}one{gato}other{gatos}}',
|
||||
},
|
||||
en: {
|
||||
hi: 'hi yo',
|
||||
'switch.lang': 'Switch language',
|
||||
greeting: {
|
||||
ask: 'Please type your name',
|
||||
message: 'Hello {name}, how are you?',
|
||||
},
|
||||
photos:
|
||||
'You have {n, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}',
|
||||
cats: 'I have {n, number} {n,plural,one{cat}other{cats}}',
|
||||
},
|
||||
}
|
||||
|
||||
i18n(store, { dictionary: [locales] })
|
||||
format.subscribe(formatFn => {
|
||||
_ = formatFn
|
||||
})
|
||||
dictionary.set(dict)
|
||||
locale.subscribe(l => (currentLocale = l))
|
||||
locale.set('pt')
|
||||
|
||||
describe('Utilities', () => {
|
||||
it('should check if a variable is an object', () => {
|
||||
expect(isObject({})).toBe(true)
|
||||
expect(isObject(1)).toBe(false)
|
||||
})
|
||||
it('should change locale', () => {
|
||||
locale.set('pt')
|
||||
expect(currentLocale).toBe('pt')
|
||||
locale.set('en')
|
||||
expect(currentLocale).toBe('en')
|
||||
})
|
||||
|
||||
describe('Localization', () => {
|
||||
beforeEach(() => {
|
||||
console.error = jest.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
console.error.mockRestore()
|
||||
})
|
||||
|
||||
it('should start with a clean store', () => {
|
||||
const { _, locale } = store.get()
|
||||
expect(locale).toBeFalsy()
|
||||
expect(_).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should change the locale after a "locale" store event', () => {
|
||||
store.fire('locale', 'pt-br')
|
||||
const { locale, _ } = store.get()
|
||||
|
||||
expect(locale).toBe('pt-br')
|
||||
expect(_).toBeInstanceOf(Function)
|
||||
})
|
||||
|
||||
it('should have a .i18n.setLocale() method', () => {
|
||||
expect(store.i18n.setLocale).toBeInstanceOf(Function)
|
||||
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { locale } = store.get()
|
||||
|
||||
expect(locale).toBe('pt-br')
|
||||
})
|
||||
|
||||
it('should handle nonexistent locale', () => {
|
||||
expect(store.i18n.setLocale('foo'))
|
||||
expect(console.error).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should return the message id when no message identified by it was found', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(_('non.existent')).toBe('non.existent')
|
||||
})
|
||||
|
||||
it('should get a message by its id', () => {
|
||||
const { _ } = store.get()
|
||||
expect(_('test')).toBe(locales['pt-br'].test)
|
||||
})
|
||||
|
||||
it('should get a deep nested message by its string path', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(_('obj.b')).toBe('b')
|
||||
})
|
||||
|
||||
it('should get a message within an array by its index', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(_('phrases[1]')).toBe(locales['pt-br'].phrases[1])
|
||||
|
||||
/** Not found */
|
||||
expect(_('phrases[2]')).toBe('phrases[2]')
|
||||
})
|
||||
|
||||
it('should interpolate with {numeric} placeholders', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(_('interpolation.key', ['Chris', 'o dia'])).toBe(
|
||||
'Olá, Chris! Como está o dia?',
|
||||
)
|
||||
})
|
||||
|
||||
it('should interpolate with {named} placeholders', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(
|
||||
_('interpolation.named', {
|
||||
name: 'Chris',
|
||||
time: 'o dia',
|
||||
}),
|
||||
).toBe('Olá, Chris! Como está o dia?')
|
||||
})
|
||||
|
||||
it('should handle pluralization with _.plural()', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(_.plural('simplePluralization')).toBe('Plural')
|
||||
expect(_.plural('simplePluralization', 1)).toBe('Singular')
|
||||
expect(_.plural('simplePluralization', 3)).toBe('Plural')
|
||||
expect(_.plural('simplePluralization', -23)).toBe('Plural')
|
||||
expect(_.plural('pluralization')).toBe('Zero')
|
||||
expect(_.plural('pluralization', 0)).toBe('Zero')
|
||||
expect(_.plural('pluralization', 1)).toBe('Um')
|
||||
expect(_.plural('pluralization', -1)).toBe('Um')
|
||||
expect(_.plural('pluralization', -1000)).toBe('Muito!')
|
||||
expect(_.plural('pluralization', 2)).toBe('Muito!')
|
||||
expect(_.plural('pluralization', 100)).toBe('Muito!')
|
||||
expect(_.plural('interpolationPluralization', 1)).toBe('One thingie')
|
||||
expect(_.plural('interpolationPluralization', 10, [10])).toBe('10 thingies')
|
||||
})
|
||||
it('should fallback to message id if id is not found', () => {
|
||||
expect(_('batatinha')).toBe('batatinha')
|
||||
})
|
||||
|
||||
describe('Localization utilities', () => {
|
||||
it('should translate to current locale', () => {
|
||||
locale.set('pt')
|
||||
expect(_('switch.lang')).toBe('Trocar idioma')
|
||||
locale.set('en')
|
||||
expect(_('switch.lang')).toBe('Switch language')
|
||||
})
|
||||
|
||||
it('should translate to passed locale', () => {
|
||||
expect(_('switch.lang', 'pt')).toBe('Trocar idioma')
|
||||
expect(_('switch.lang', 'en')).toBe('Switch language')
|
||||
})
|
||||
|
||||
it('should interpolate message with variables', () => {
|
||||
expect(_('greeting.message', { name: 'Chris' })).toBe(
|
||||
'Hello Chris, how are you?',
|
||||
)
|
||||
})
|
||||
|
||||
it('should interpolate message with variables according to passed locale', () => {
|
||||
expect(_('greeting.message', { name: 'Chris' }, 'pt')).toBe(
|
||||
'Olá Chris, como vai?',
|
||||
)
|
||||
})
|
||||
|
||||
describe('utilities', () => {
|
||||
beforeAll(() => {
|
||||
locale.set('en')
|
||||
})
|
||||
|
||||
it('should capital a translated message', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(capital('Adoro banana')).toBe('Adoro banana')
|
||||
expect(_.capital('phrase')).toBe('Adoro banana')
|
||||
expect(_.capital('hi')).toBe('Hi yo')
|
||||
})
|
||||
|
||||
it('should title a translated message', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(title('Adoro Banana')).toBe('Adoro Banana')
|
||||
expect(_.title('phrase')).toBe('Adoro Banana')
|
||||
expect(_.title('hi')).toBe('Hi Yo')
|
||||
})
|
||||
|
||||
it('should lowercase a translated message', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(lower('adoro banana')).toBe('adoro banana')
|
||||
expect(_.lower('phrase')).toBe('adoro banana')
|
||||
expect(_.lower('hi')).toBe('hi yo')
|
||||
})
|
||||
|
||||
it('should uppercase a translated message', () => {
|
||||
store.i18n.setLocale('pt-br')
|
||||
const { _ } = store.get()
|
||||
|
||||
expect(upper('ADORO BANANA')).toBe('ADORO BANANA')
|
||||
expect(_.upper('phrase')).toBe('ADORO BANANA')
|
||||
expect(_.upper('hi')).toBe('HI YO')
|
||||
})
|
||||
|
||||
const date = new Date(2019, 3, 24, 23, 45)
|
||||
it('should format a time value', () => {
|
||||
locale.set('en')
|
||||
expect(_.time(date)).toBe('11:45 PM')
|
||||
expect(_.time(date, 'medium')).toBe('11:45:00 PM')
|
||||
})
|
||||
|
||||
it('should format a date value', () => {
|
||||
expect(_.date(date)).toBe('4/24/19')
|
||||
expect(_.date(date, 'medium')).toBe('Apr 24, 2019')
|
||||
})
|
||||
// number
|
||||
it('should format a date value', () => {
|
||||
expect(_.number(123123123)).toBe('123,123,123')
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user