Merge pull request #25 from kaisermann/fix/v2

V2 again
This commit is contained in:
Christian Kaisermann 2019-11-29 22:19:29 -03:00 committed by GitHub
commit 7ff154cc44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 10745 additions and 3965 deletions

View File

@ -1 +1,3 @@
/test/fixtures
/test/fixtures
dist/
example/

View File

@ -1,10 +1,13 @@
{
"extends": ["kaisermann"],
"extends": ["kaisermann/typescript"],
"env": {
"browser": true,
"jest": true
},
"parserOptions": {
"sourceType": "module"
},
"rules": {
"@typescript-eslint/camelcase": "off"
}
}

View File

@ -15,7 +15,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
node-version: [8, 10, 12]
node-version: [13.2.0]
os: [ubuntu-latest, windows-latest, macOS-latest]
steps:

3
.gitignore vendored
View File

@ -1,4 +1,5 @@
node_modules
*.log
dist/
coverage/
coverage/
docs/

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
13.2.0

View File

@ -1,7 +1,7 @@
{
"semi": false,
"printWidth": 80,
"trailingComma": "all",
"trailingComma": "es5",
"bracketSpacing": true,
"jsxBracketSameLine": false,
"singleQuote": true

71
CHANGELOG.md Normal file
View File

@ -0,0 +1,71 @@
# [2.1.0](https://github.com/kaisermann/svelte-i18n/compare/v2.1.0-alpha.2...v2.1.0) (2019-11-30)
# [2.1.0-alpha.2](https://github.com/kaisermann/svelte-i18n/compare/v2.0.2...v2.1.0-alpha.2) (2019-11-29)
### Bug Fixes
* 🐛 allow to wait for initial locale load ([0b7f61c](https://github.com/kaisermann/svelte-i18n/commit/0b7f61c49a1c3206bbb5d9c77dfb5819a85d4bb5))
### Features
* 🎸 add warnOnMissingMessages ([efbe793](https://github.com/kaisermann/svelte-i18n/commit/efbe793a0f3656b27d050886d85e06e9327ea681))
## [2.0.2](https://github.com/kaisermann/svelte-i18n/compare/v2.0.0...v2.0.2) (2019-11-24)
### Bug Fixes
* 🐛 fallback behaviour and simplify API contact points ([6e0df2f](https://github.com/kaisermann/svelte-i18n/commit/6e0df2fb25e1bf9038eb4252ba993541a7fa2b4a))
* 🐛 fallback locale being looked twice ([9260b83](https://github.com/kaisermann/svelte-i18n/commit/9260b83756b9bb2811be48817190510fa24cb2ff))
## [2.0.1](https://github.com/kaisermann/svelte-i18n/compare/v2.0.0...v2.0.1) (2019-11-24)
### Bug Fixes
* 🐛 fallback behaviour and simplify API contact points ([64e69eb](https://github.com/kaisermann/svelte-i18n/commit/64e69eb3c0f62754570429a87450ff53eb29973a))
# [2.0.0](https://github.com/kaisermann/svelte-i18n/compare/v1.1.2-beta...v2.0.0) (2019-11-22)
### Bug Fixes
* 🐛 consider generic locales when registering loaders ([1b0138c](https://github.com/kaisermann/svelte-i18n/commit/1b0138c3f3458c4d8f0b30b4550652e8e0317fc7))
* 🐛 flush use the same promise if it wasn't resolved yet ([66972d4](https://github.com/kaisermann/svelte-i18n/commit/66972d4b1536b53d33c7974eb0fc059c0d0cc46c))
* client locale parameters typo ([#11](https://github.com/kaisermann/svelte-i18n/issues/11)) ([d1adf4c](https://github.com/kaisermann/svelte-i18n/commit/d1adf4c00a48ed679ae34a2bffc8ca9d709a2d5c))
### Features
* 🎸 `addMessagesTo` method ([d6b8664](https://github.com/kaisermann/svelte-i18n/commit/d6b8664009d738870aa3f0a4bd80e96abf6e6e59))
* 🎸 add $loading indicator store ([bd2b350](https://github.com/kaisermann/svelte-i18n/commit/bd2b3501e9caa2e73f64835fedf93dc8939d41de))
* 🎸 add custom formats support ([d483244](https://github.com/kaisermann/svelte-i18n/commit/d483244a9f2bb5ba63ef8be95f0e87030b5cbc7e))
* 🎸 add pathname and hostname pattern matching ([b19b690](https://github.com/kaisermann/svelte-i18n/commit/b19b69050e252120016d47540e108f6eea193c37))
* 🎸 add preloadLocale method ([0a0e4b3](https://github.com/kaisermann/svelte-i18n/commit/0a0e4b3bab74499d684c86e17c949160762ae19b))
* 🎸 add waitInitialLocale helper ([6ee28e7](https://github.com/kaisermann/svelte-i18n/commit/6ee28e7d279c62060e834699714685567b6ab67c))
* 🎸 also look for message in generic locale ([e5d7b84](https://github.com/kaisermann/svelte-i18n/commit/e5d7b84241bd7e3fdd833e82dd8a9a8f251f023c)), closes [#19](https://github.com/kaisermann/svelte-i18n/issues/19)
* 🎸 export a store listing all locales available ([f58a20b](https://github.com/kaisermann/svelte-i18n/commit/f58a20b21eb58f891b3f9912cb6fff11eb329083))
* 🎸 locale change automatically updates the document lang ([64c8e55](https://github.com/kaisermann/svelte-i18n/commit/64c8e55f80636a1185a1797fe486b4189ff56944))
### Performance Improvements
* ⚡️ delay the $loading state change for quick loadings ([6573f51](https://github.com/kaisermann/svelte-i18n/commit/6573f51e9b817db0c77f158945572f4ba14c71fc))
### BREAKING CHANGES
* This PR modifies the formatter method arguments.

265
README.md
View File

@ -1,247 +1,48 @@
[![npm version](https://badge.fury.io/js/svelte-i18n.svg)](https://badge.fury.io/js/svelte-i18n) ![](https://github.com/kaisermann/svelte-i18n/workflows/CI/badge.svg)
# svelte-i18n
> Internationalization for Svelte.
**Note:** the `v2` version was unpublished and will be released again in the following week after I rewrite all of its tests. For now, use the `v1.1.2`.
`svelte-i18n` helps you localize your app using the reactive tools Svelte provides. By using [stores](https://svelte.dev/docs#svelte_store) to keep track of the current `locale`, `dictionary` of messages and to `format` messages, we keep everything neat, in sync and easy to use on your svelte files.
## Usage
**Requirements**
`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.
- Node: `>= 11.15.0`
- Browsers: `Chrome 38+`, `Edge 12+`, `Firefox 13+`, `Opera 25+`, `Safari 8+`.
---
### Locale
The `locale` store defines what is the current locale.
```js
import { locale, dictionary, getClientLocale } from 'svelte-i18n'
// Set the current locale to en-US
locale.set('en-US')
// This is a store, so we can subscribe to its changes
locale.subscribe(() => {
console.log('locale change')
})
// svelte-i18n exports a method to help getting the current client locale
locale.set(
getClientLocale({
// the fallback locale, if didn't find any
fallback: 'en-US',
// set to 'true' to check the 'window.navigator.language'
navigator: true,
// set the key name to look for a locale on 'window.location.search'
// 'example.com?locale=en-US'
search: 'lang',
// set the key name to look for a locale on 'window.location.hash'
// 'example.com#locale=en-US'
hash: 'locale',
}),
)
```
If a locale with the format `xx-YY` is not found, `svelte-i18n` looks for the locale `xx` as well.
---
### 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?',
},
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}}',
},
})
// It's also possible to merge the current dictionary
// with other objets
dictionary.update(dict => {
dict.fr = {
// ...french messages
}
return dict
})
```
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`).
---
### 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>
{$_('greeting.message', { name: 'John' })}
<!-- Hello John, how are you? -->
{$_('photos', { n: 0 })}
<!-- You have no photos. -->
{$_('photos', { n: 12 })}
<!-- You have 12 photos. -->
</div>
```
### Formatting methods
#### `_` / `format`
`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
```svelte
<script>
import { _ } from 'svelte-i18n'
</script>
<div>{$_('greeting.ask')}</div>
<!-- Please type your name -->
<h1>{$_('page.home.title')}</h1>
<nav>
<a>{$_('page.home.nav', { default: 'Home' })}</a>
<a>{$_('page.about.nav', { default: 'About' })}</a>
<a>{$_('page.contact.nav', { default: 'Contact' })}</a>
</nav>
```
#### `_.upper`
Transforms a localized message into uppercase.
```html
<script>
import { _ } from 'svelte-i18n'
</script>
<div>{$_.upper('greeting.ask')}</div>
<!-- PLEASE TYPE YOUR NAME -->
```jsonc
// en.json
{
"page": {
"home": {
"title": "Homepage",
"nav": "Home"
},
"about": {
"title": "About",
"nav": "About"
},
"contact": {
"title": "Contact",
"nav": "Contact Us"
}
}
}
```
#### `_.lower`
Transforms a localized message into lowercase.
```html
<script>
import { _ } from 'svelte-i18n'
</script>
<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 -->
```
### [Go to documentation](https://github.com/kaisermann/svelte-i18n/wiki)

7
example/.gitignore vendored
View File

@ -1,3 +1,6 @@
.DS_Store
node_modules
public/bundle.*
/node_modules/
/src/node_modules/@sapper/
yarn-error.log
/cypress/screenshots/
/__sapper__/

View File

@ -1,68 +1,109 @@
*Psst — looking for a shareable component template? Go here --> [sveltejs/component-template](https://github.com/sveltejs/component-template)*
# sapper-template
---
The default [Sapper](https://github.com/sveltejs/sapper) template, available for Rollup and webpack.
# svelte app
This is a project template for [Svelte](https://svelte.dev) apps. It lives at https://github.com/sveltejs/template.
## Getting started
To create a new project based on this template using [degit](https://github.com/Rich-Harris/degit):
### Using `degit`
[`degit`](https://github.com/Rich-Harris/degit) is a scaffolding tool that lets you create a directory from a branch in a repository. Use either the `rollup` or `webpack` branch in `sapper-template`:
```bash
npx degit sveltejs/template svelte-app
cd svelte-app
# for Rollup
npx degit "sveltejs/sapper-template#rollup" my-app
# for webpack
npx degit "sveltejs/sapper-template#webpack" my-app
```
*Note that you will need to have [Node.js](https://nodejs.org) installed.*
### Using GitHub templates
Alternatively, you can use GitHub's template feature with the [sapper-template-rollup](https://github.com/sveltejs/sapper-template-rollup) or [sapper-template-webpack](https://github.com/sveltejs/sapper-template-webpack) repositories.
## Get started
### Running the project
Install the dependencies...
```bash
cd svelte-app
npm install
```
...then start [Rollup](https://rollupjs.org):
However you get the code, you can install dependencies and run the project in development mode with:
```bash
cd my-app
npm install # or yarn
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.
Open up [localhost:3000](http://localhost:3000) and start clicking around.
Consult [sapper.svelte.dev](https://sapper.svelte.dev) for help getting started.
## Deploying to the web
## Structure
### With [now](https://zeit.co/now)
Sapper expects to find two directories in the root of your project — `src` and `static`.
Install `now` if you haven't already:
### src
The [src](src) directory contains the entry points for your app — `client.js`, `server.js` and (optionally) a `service-worker.js` — along with a `template.html` file and a `routes` directory.
#### src/routes
This is the heart of your Sapper app. There are two kinds of routes — *pages*, and *server routes*.
**Pages** are Svelte components written in `.svelte` files. When a user first visits the application, they will be served a server-rendered version of the route in question, plus some JavaScript that 'hydrates' the page and initialises a client-side router. From that point forward, navigating to other pages is handled entirely on the client for a fast, app-like feel. (Sapper will preload and cache the code for these subsequent pages, so that navigation is instantaneous.)
**Server routes** are modules written in `.js` files, that export functions corresponding to HTTP methods. Each function receives Express `request` and `response` objects as arguments, plus a `next` function. This is useful for creating a JSON API, for example.
There are three simple rules for naming the files that define your routes:
* A file called `src/routes/about.svelte` corresponds to the `/about` route. A file called `src/routes/blog/[slug].svelte` corresponds to the `/blog/:slug` route, in which case `params.slug` is available to the route
* The file `src/routes/index.svelte` (or `src/routes/index.js`) corresponds to the root of your app. `src/routes/about/index.svelte` is treated the same as `src/routes/about.svelte`.
* Files and directories with a leading underscore do *not* create routes. This allows you to colocate helper modules and components with the routes that depend on them — for example you could have a file called `src/routes/_helpers/datetime.js` and it would *not* create a `/_helpers/datetime` route
### static
The [static](static) directory contains any static assets that should be available. These are served using [sirv](https://github.com/lukeed/sirv).
In your [service-worker.js](src/service-worker.js) file, you can import these as `files` from the generated manifest...
```js
import { files } from '@sapper/service-worker';
```
...so that you can cache them (though you can choose not to, for example if you don't want to cache very large files).
## Bundler config
Sapper uses Rollup or webpack to provide code-splitting and dynamic imports, as well as compiling your Svelte components. With webpack, it also provides hot module reloading. As long as you don't do anything daft, you can edit the configuration files to add whatever plugins you'd like.
## Production mode and deployment
To start a production version of your app, run `npm run build && npm start`. This will disable live reloading, and activate the appropriate bundler plugins.
You can deploy your application to any environment that supports Node 8 or above. As an example, to deploy to [Now](https://zeit.co/now), run these commands:
```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/)
## Using external components
Install `surge` if you haven't already:
When using Svelte components installed from npm, such as [@sveltejs/svelte-virtual-list](https://github.com/sveltejs/svelte-virtual-list), Svelte needs the original component source (rather than any precompiled JavaScript that ships with the component). This allows the component to be rendered server-side, and also keeps your client-side app smaller.
Because of that, it's essential that the bundler doesn't treat the package as an *external dependency*. You can either modify the `external` option under `server` in [rollup.config.js](rollup.config.js) or the `externals` option in [webpack.config.js](webpack.config.js), or simply install the package to `devDependencies` rather than `dependencies`, which will cause it to get bundled (and therefore compiled) with your app:
```bash
npm install -g surge
npm install -D @sveltejs/svelte-virtual-list
```
Then, from within your project folder:
```bash
npm run build
surge public
```
## Bugs and feedback
Sapper is in early development, and may have the odd rough edge here and there. Please be vocal over on the [Sapper issue tracker](https://github.com/sveltejs/sapper/issues).

20
example/messages/en.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": {
"index": "Sapper project template!"
},
"nav": {
"home": "Home",
"about": "About",
"blog": "Blog"
},
"messages": {
"success": "Great success!",
"high_five": "High five",
"try_editing": "Try editing this file (src/routes/index.svelte) to test live reloading."
},
"languages": {
"pt_BR": "Portugues",
"en": "English",
"es_ES": "Spanish"
}
}

View File

@ -0,0 +1,20 @@
{
"title": {
"index": " Plantilla de proyecto Sapper!"
},
"nav": {
"home": "Inicio",
"about": "Acerca",
"blog": "Blog"
},
"messages": {
"success": "Gran éxito!",
"high_five": "Cinco altos",
"try_editing": " Intente editar este archivo (src/routes/index.svelte) para probar la recarga en vivo."
},
"languages": {
"pt_BR": "Portugués",
"en": "Ingles",
"es_ES": "Espanol"
}
}

View File

@ -0,0 +1,20 @@
{
"title": {
"index": "Modelo de projeto em Sapper!"
},
"nav": {
"home": "Home",
"about": "Sobre",
"blog": "Blog"
},
"messages": {
"success": "Suuuucesso!",
"high_five": "Toca aqui",
"try_editing": "Tente editar este arquivo (src/routes/index.svelte) para testar o recarregamento ao vivo."
},
"languages": {
"pt_BR": "Português",
"en": "Inglês",
"es_ES": "Espanhol"
}
}

3437
example/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,33 @@
{
"name": "svelte-app",
"version": "1.0.0",
"version": "0.0.1",
"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.15.6",
"rollup-plugin-commonjs": "^10.0.0",
"rollup-plugin-livereload": "^1.0.1",
"rollup-plugin-node-resolve": "^5.0.3",
"rollup-plugin-svelte": "^5.1.0",
"rollup-plugin-terser": "^5.0.0",
"sirv-cli": "^0.4.4",
"svelte": "^3.5.1"
"dev": "sapper dev",
"build": "sapper build --legacy",
"export": "export NODE_ENV=development; sapper export --legacy",
"start": "node __sapper__/build"
},
"dependencies": {
"svelte-i18n": "^1.1.0"
"compression": "^1.7.1",
"polka": "next",
"sirv": "^0.4.0",
"svelte-i18n": "latest"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/plugin-transform-runtime": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/runtime": "^7.0.0",
"@rollup/plugin-json": "^4.0.0",
"@rollup/plugin-replace": "^2.2.0",
"npm-run-all": "^4.1.5",
"rollup": "^1.12.0",
"rollup-plugin-babel": "^4.0.2",
"rollup-plugin-commonjs": "^10.0.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-svelte": "^5.0.1",
"rollup-plugin-terser": "^4.0.4",
"sapper": "^0.27.0",
"svelte": "^3.0.0"
}
}

View File

@ -0,0 +1,2 @@
/*# sourceMappingURL=bundle.css.map */

3319
example/public/bundle.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,61 +0,0 @@
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;
}

View File

@ -1,21 +0,0 @@
<!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>

View File

@ -1,44 +1,109 @@
import svelte from 'rollup-plugin-svelte'
import resolve from 'rollup-plugin-node-resolve'
import replace from '@rollup/plugin-replace'
import commonjs from 'rollup-plugin-commonjs'
import livereload from 'rollup-plugin-livereload'
import svelte from 'rollup-plugin-svelte'
import babel from 'rollup-plugin-babel'
import json from '@rollup/plugin-json'
import { terser } from 'rollup-plugin-terser'
import config from 'sapper/config/rollup.js'
const production = !process.env.ROLLUP_WATCH
import pkg from './package.json'
const mode = process.env.NODE_ENV
const dev = mode === 'development'
const legacy = !!process.env.SAPPER_LEGACY_BUILD
const onwarn = (warning, onwarn) =>
(warning.code === 'CIRCULAR_DEPENDENCY' &&
/[/\\]@sapper[/\\]/.test(warning.message)) ||
onwarn(warning)
const dedupe = importee =>
importee === 'svelte' || importee.startsWith('svelte/')
export default {
input: 'src/main.js',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/bundle.js',
client: {
input: config.client.input(),
output: config.client.output(),
plugins: [
replace({
'process.browser': true,
'process.env.NODE_ENV': JSON.stringify(mode),
}),
svelte({
dev,
hydratable: true,
emitCss: true,
}),
resolve({
browser: true,
dedupe,
}),
commonjs(),
json({
namedExports: false,
compact: !dev,
}),
legacy &&
babel({
extensions: ['.js', '.mjs', '.html', '.svelte'],
runtimeHelpers: true,
exclude: ['node_modules/@babel/**'],
presets: [['@babel/preset-env', { targets: '> 0.25%, not dead' }]],
plugins: [
'@babel/plugin-syntax-dynamic-import',
['@babel/plugin-transform-runtime', { useESModules: true }],
],
}),
!dev &&
terser({
module: true,
}),
],
onwarn,
},
server: {
input: config.server.input(),
output: config.server.output(),
plugins: [
replace({
'process.browser': false,
'process.env.NODE_ENV': JSON.stringify(mode),
}),
svelte({
generate: 'ssr',
dev,
}),
resolve({
dedupe,
}),
commonjs(),
json({
namedExports: false,
compact: !dev,
}),
],
external: Object.keys(pkg.dependencies).concat(
require('module').builtinModules ||
Object.keys(process.binding('natives'))
),
onwarn,
},
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(),
serviceworker: {
input: config.serviceworker.input(),
output: config.serviceworker.output(),
plugins: [
resolve(),
replace({
'process.browser': true,
'process.env.NODE_ENV': JSON.stringify(mode),
}),
commonjs(),
!dev && terser(),
],
// 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(),
],
onwarn,
},
}

View File

@ -1,46 +0,0 @@
<script>
import { locale, _ } from '../../src/index.js'
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', { values: { name } })}</h1>
<br />
<input type="range" min="0" max="5" step="1" bind:value={pluralN} />
<h2>Plural: {$_('photos', { values: { n: pluralN } })}</h2>
<br />
<input type="range" min="100" max="100000000" step="10000" bind:value={catsN} />
<h2>Number: {$_('cats', { values: { n: catsN } })}</h2>
<br />
<h2>Number util: {$_.number(catsN)}</h2>
<h2>Number util: {$_.number(10000000, { format: 'compactShort' })}</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', { locale: oppositeLocale })}
</button>

7
example/src/client.js Normal file
View File

@ -0,0 +1,7 @@
import * as sapper from '@sapper/app'
import './i18n.js'
sapper.start({
target: document.querySelector('#sapper'),
})

View File

@ -0,0 +1,86 @@
<script>
import { _, locale, locales } from 'svelte-i18n'
export let segment
</script>
<style>
nav {
border-bottom: 1px solid rgba(255, 62, 0, 0.1);
font-weight: 300;
padding: 0 1em;
display: flex;
justify-content: space-between;
}
ul {
margin: 0;
padding: 0;
}
/* clearfix */
ul::after {
content: '';
display: block;
clear: both;
}
li {
display: block;
float: left;
}
.selected {
position: relative;
display: inline-block;
}
.selected::after {
position: absolute;
content: '';
width: calc(100% - 1em);
height: 2px;
background-color: rgb(255, 62, 0);
display: block;
bottom: -1px;
}
a,
.a {
cursor: pointer;
text-decoration: none;
padding: 1em 0.5em;
display: block;
}
</style>
<nav>
<ul>
<li>
<a class:selected={segment === undefined} href=".">{$_('nav.home')}</a>
</li>
<li>
<a class:selected={segment === 'about'} href="about">{$_('nav.about')}</a>
</li>
<!-- for the blog link, we're using rel=prefetch so that Sapper prefetches
the blog data when we hover over the link or tap it on a touchscreen -->
<li>
<a rel="prefetch" class:selected={segment === 'blog'} href="blog">
{$_('nav.blog')}
</a>
</li>
</ul>
<ul class="lang">
{#each $locales as item}
<li>
<span
class="a"
class:selected={$locale.includes(item)}
href={`#!${item}`}
on:click={() => ($locale = item)}>
{item.replace('-', '_')}
</span>
</li>
{/each}
</ul>
</nav>

View File

@ -1,51 +1,10 @@
import {
locale,
dictionary,
getClientLocale,
addCustomFormats,
} from '../../src/index.js'
import { register, init } from 'svelte-i18n'
addCustomFormats({
number: {
compact: {
notation: 'compact',
compactDisplay: 'long',
},
},
})
// 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}}',
},
})
locale.set(
getClientLocale({
navigator: true,
hash: 'lang',
fallback: 'pt',
}),
)
locale.subscribe(l => {
console.log('locale change', l)
register('en', () => import('../messages/en.json'))
register('pt-BR', () => import('../messages/pt-BR.json'))
register('es-ES', () => import('../messages/es-ES.json'))
init({
fallbackLocale: 'en',
initialLocale: { navigator: true },
})

View File

@ -1,9 +0,0 @@
import './i18n.js'
import App from './App.svelte'
const app = new App({
target: document.body,
props: { name: 'world' },
})
export default app

View File

@ -0,0 +1,40 @@
<script>
export let status;
export let error;
const dev = process.env.NODE_ENV === 'development';
</script>
<style>
h1, p {
margin: 0 auto;
}
h1 {
font-size: 2.8em;
font-weight: 700;
margin: 0 0 0.5em 0;
}
p {
margin: 1em auto;
}
@media (min-width: 480px) {
h1 {
font-size: 4em;
}
}
</style>
<svelte:head>
<title>{status}</title>
</svelte:head>
<h1>{status}</h1>
<p>{error.message}</p>
{#if dev && error.stack}
<pre>{error.stack}</pre>
{/if}

View File

@ -0,0 +1,50 @@
<script context="module">
import { isLoading, waitLocale } from 'svelte-i18n'
export async function preload() {
return waitLocale()
}
</script>
<script>
import Nav from '../components/Nav.svelte'
export let segment
</script>
<style>
main {
position: relative;
max-width: 56em;
background-color: white;
padding: 2em;
margin: 0 auto;
box-sizing: border-box;
}
.loading {
position: fixed;
z-index: 10;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-family: monospace;
font-size: 4rem;
display: flex;
justify-content: center;
align-items: center;
}
</style>
{#if $isLoading}
<div class="loading">Loading...</div>
{/if}
<Nav {segment} />
<main>
<slot />
</main>

View File

@ -0,0 +1,7 @@
{
"title": {
"about": "About"
},
"about_this_site": "About this site",
"about_content": ["This is the 'about' page. There's not much here."]
}

View File

@ -0,0 +1,7 @@
{
"title": {
"about": "About"
},
"about_this_site": "About this site",
"about_content": ["This is the 'about' page. There's not much here."]
}

View File

@ -0,0 +1,7 @@
{
"title": {
"about": "Acerca de"
},
"about_this_site": " Acerca de este sitio",
"about_content": ["Esta es la página 'acerca de'. No hay mucho aquí."]
}

View File

@ -0,0 +1,7 @@
{
"title": {
"about": "Sobre"
},
"about_this_site": "Sobre este site",
"about_content": ["Esta é a página 'sobre'. Não há muito aqui."]
}

View File

@ -0,0 +1,23 @@
<script context="module">
import { register, waitLocale } from 'svelte-i18n'
register('en', () => import('./_locales/en.json'))
register('pt-BR', () => import('./_locales/pt-BR.json'))
register('es-ES', () => import('./_locales/es-ES.json'))
export async function preload(page, session) {
return waitLocale()
}
</script>
<script>
import { _ } from 'svelte-i18n'
</script>
<svelte:head>
<title>{$_('title.about')}</title>
</svelte:head>
<h1>{$_('about_this_site')}</h1>
<p>{$_('about_content.0')}</p>

View File

@ -0,0 +1,28 @@
import posts from './_posts.js';
const lookup = new Map();
posts.forEach(post => {
lookup.set(post.slug, JSON.stringify(post));
});
export function get(req, res, next) {
// the `slug` parameter is available because
// this file is called [slug].json.js
const { slug } = req.params;
if (lookup.has(slug)) {
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(lookup.get(slug));
} else {
res.writeHead(404, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify({
message: `Not found`
}));
}
}

View File

@ -0,0 +1,64 @@
<script context="module">
export async function preload({ params, query }) {
// the `slug` parameter is available because
// this file is called [slug].svelte
const res = await this.fetch(`blog/${params.slug}.json`);
const data = await res.json();
if (res.status === 200) {
return { post: data };
} else {
this.error(res.status, data.message);
}
}
</script>
<script>
export let post;
</script>
<style>
/*
By default, CSS is locally scoped to the component,
and any unused styles are dead-code-eliminated.
In this page, Svelte can't know which elements are
going to appear inside the {{{post.html}}} block,
so we have to use the :global(...) modifier to target
all elements inside .content
*/
.content :global(h2) {
font-size: 1.4em;
font-weight: 500;
}
.content :global(pre) {
background-color: #f9f9f9;
box-shadow: inset 1px 1px 5px rgba(0,0,0,0.05);
padding: 0.5em;
border-radius: 2px;
overflow-x: auto;
}
.content :global(pre) :global(code) {
background-color: transparent;
padding: 0;
}
.content :global(ul) {
line-height: 1.5;
}
.content :global(li) {
margin: 0 0 0.5em 0;
}
</style>
<svelte:head>
<title>{post.title}</title>
</svelte:head>
<h1>{post.title}</h1>
<div class='content'>
{@html post.html}
</div>

View File

@ -0,0 +1,92 @@
// Ordinarily, you'd generate this data from markdown files in your
// repo, or fetch them from a database of some kind. But in order to
// avoid unnecessary dependencies in the starter template, and in the
// service of obviousness, we're just going to leave it here.
// This file is called `_posts.js` rather than `posts.js`, because
// we don't want to create an `/blog/posts` route — the leading
// underscore tells Sapper not to do that.
const posts = [
{
title: 'What is Sapper?',
slug: 'what-is-sapper',
html: `
<p>First, you have to know what <a href='https://svelte.dev'>Svelte</a> is. Svelte is a UI framework with a bold new idea: rather than providing a library that you write code with (like React or Vue, for example), it's a compiler that turns your components into highly optimized vanilla JavaScript. If you haven't already read the <a href='https://svelte.dev/blog/frameworks-without-the-framework'>introductory blog post</a>, you should!</p>
<p>Sapper is a Next.js-style framework (<a href='blog/how-is-sapper-different-from-next'>more on that here</a>) built around Svelte. It makes it embarrassingly easy to create extremely high performance web apps. Out of the box, you get:</p>
<ul>
<li>Code-splitting, dynamic imports and hot module replacement, powered by webpack</li>
<li>Server-side rendering (SSR) with client-side hydration</li>
<li>Service worker for offline support, and all the PWA bells and whistles</li>
<li>The nicest development experience you've ever had, or your money back</li>
</ul>
<p>It's implemented as Express middleware. Everything is set up and waiting for you to get started, but you keep complete control over the server, service worker, webpack config and everything else, so it's as flexible as you need it to be.</p>
`
},
{
title: 'How to use Sapper',
slug: 'how-to-use-sapper',
html: `
<h2>Step one</h2>
<p>Create a new project, using <a href='https://github.com/Rich-Harris/degit'>degit</a>:</p>
<pre><code>npx degit "sveltejs/sapper-template#rollup" my-app
cd my-app
npm install # or yarn!
npm run dev
</code></pre>
<h2>Step two</h2>
<p>Go to <a href='http://localhost:3000'>localhost:3000</a>. Open <code>my-app</code> in your editor. Edit the files in the <code>src/routes</code> directory or add new ones.</p>
<h2>Step three</h2>
<p>...</p>
<h2>Step four</h2>
<p>Resist overdone joke formats.</p>
`
},
{
title: 'Why the name?',
slug: 'why-the-name',
html: `
<p>In war, the soldiers who build bridges, repair roads, clear minefields and conduct demolitions all under combat conditions are known as <em>sappers</em>.</p>
<p>For web developers, the stakes are generally lower than those for combat engineers. But we face our own hostile environment: underpowered devices, poor network connections, and the complexity inherent in front-end engineering. Sapper, which is short for <strong>S</strong>velte <strong>app</strong> mak<strong>er</strong>, is your courageous and dutiful ally.</p>
`
},
{
title: 'How is Sapper different from Next.js?',
slug: 'how-is-sapper-different-from-next',
html: `
<p><a href='https://github.com/zeit/next.js'>Next.js</a> is a React framework from <a href='https://zeit.co'>Zeit</a>, and is the inspiration for Sapper. There are a few notable differences, however:</p>
<ul>
<li>It's powered by <a href='https://svelte.dev'>Svelte</a> instead of React, so it's faster and your apps are smaller</li>
<li>Instead of route masking, we encode route parameters in filenames. For example, the page you're looking at right now is <code>src/routes/blog/[slug].html</code></li>
<li>As well as pages (Svelte components, which render on server or client), you can create <em>server routes</em> in your <code>routes</code> directory. These are just <code>.js</code> files that export functions corresponding to HTTP methods, and receive Express <code>request</code> and <code>response</code> objects as arguments. This makes it very easy to, for example, add a JSON API such as the one <a href='blog/how-is-sapper-different-from-next.json'>powering this very page</a></li>
<li>Links are just <code>&lt;a&gt;</code> elements, rather than framework-specific <code>&lt;Link&gt;</code> components. That means, for example, that <a href='blog/how-can-i-get-involved'>this link right here</a>, despite being inside a blob of HTML, works with the router as you'd expect.</li>
</ul>
`
},
{
title: 'How can I get involved?',
slug: 'how-can-i-get-involved',
html: `
<p>We're so glad you asked! Come on over to the <a href='https://github.com/sveltejs/svelte'>Svelte</a> and <a href='https://github.com/sveltejs/sapper'>Sapper</a> repos, and join us in the <a href='https://svelte.dev/chat'>Discord chatroom</a>. Everyone is welcome, especially you!</p>
`
}
];
posts.forEach(post => {
post.html = post.html.replace(/^\t{3}/gm, '');
});
export default posts;

View File

@ -0,0 +1,16 @@
import posts from './_posts.js';
const contents = JSON.stringify(posts.map(post => {
return {
title: post.title,
slug: post.slug
};
}));
export function get(req, res) {
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(contents);
}

View File

@ -0,0 +1,34 @@
<script context="module">
export function preload({ params, query }) {
return this.fetch(`blog.json`).then(r => r.json()).then(posts => {
return { posts };
});
}
</script>
<script>
export let posts;
</script>
<style>
ul {
margin: 0 0 1em 0;
line-height: 1.5;
}
</style>
<svelte:head>
<title>Blog</title>
</svelte:head>
<h1>Recent posts</h1>
<ul>
{#each posts as post}
<!-- we're using the non-standard `rel=prefetch` attribute to
tell Sapper to load the data for the page as soon as
the user hovers over the link or taps it, instead of
waiting for the 'click' event -->
<li><a rel='prefetch' href='blog/{post.slug}'>{post.title}</a></li>
{/each}
</ul>

View File

@ -0,0 +1,59 @@
<script>
import { _ } from 'svelte-i18n'
</script>
<style>
h1,
figure,
p {
text-align: center;
margin: 0 auto;
}
h1 {
font-size: 2.8em;
text-transform: uppercase;
font-weight: 700;
margin: 0 0 0.5em 0;
}
figure {
margin: 0 0 1em 0;
}
img {
width: 100%;
max-width: 400px;
margin: 0 0 1em 0;
}
p {
margin: 1em auto;
}
@media (min-width: 480px) {
h1 {
font-size: 4em;
}
}
</style>
<svelte:head>
<title>{$_('title.index', { default: 'Sapper project template!' })}</title>
</svelte:head>
<h1>{$_('messages.success', { default: 'Great success!' })}</h1>
<figure>
<img alt="Borat" src="great-success.png" />
<figcaption>{$_('messages.high_five', { default: 'High five' })}</figcaption>
</figure>
<p>
<strong>
{$_('messages.try_editing', {
default:
'Try editing this file (src/routes/index.svelte) to test live reloading.',
})}
</strong>
</p>

19
example/src/server.js Normal file
View File

@ -0,0 +1,19 @@
import sirv from 'sirv'
import polka from 'polka'
import compression from 'compression'
import * as sapper from '@sapper/server'
import './i18n.js'
const { PORT, NODE_ENV } = process.env
const dev = NODE_ENV === 'development'
polka() // You can also use Express
.use(
compression({ threshold: 0 }),
sirv('static', { dev }),
sapper.middleware()
)
.listen(PORT, err => {
if (err) console.log('error', err)
})

View File

@ -0,0 +1,85 @@
import { timestamp, files, shell, routes } from '@sapper/service-worker'
const ASSETS = `cache${timestamp}`
// `shell` is an array of all the files generated by the bundler,
// `files` is an array of everything in the `static` directory
const to_cache = shell.concat(files)
const cached = new Set(to_cache)
self.addEventListener('install', event => {
event.waitUntil(
caches
.open(ASSETS)
.then(cache => cache.addAll(to_cache))
.then(() => {
self.skipWaiting()
})
)
})
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(async keys => {
// delete old caches
for (const key of keys) {
if (key !== ASSETS) await caches.delete(key)
}
self.clients.claim()
})
)
})
self.addEventListener('fetch', event => {
if (event.request.method !== 'GET' || event.request.headers.has('range'))
return
const url = new URL(event.request.url)
// don't try to handle e.g. data: URIs
if (!url.protocol.startsWith('http')) return
// ignore dev server requests
if (
url.hostname === self.location.hostname &&
url.port !== self.location.port
)
return
// always serve static files and bundler-generated assets from cache
if (url.host === self.location.host && cached.has(url.pathname)) {
event.respondWith(caches.match(event.request))
return
}
// for pages, you might want to serve a shell `service-worker-index.html` file,
// which Sapper has generated for you. It's not right for every
// app, but if it's right for yours then uncomment this section
/*
if (url.origin === self.origin && routes.find(route => route.pattern.test(url.pathname))) {
event.respondWith(caches.match('/service-worker-index.html'));
return;
}
*/
if (event.request.cache === 'only-if-cached') return
// for everything else, try the network first, falling back to
// cache if the user is offline. (If the pages never change, you
// might prefer a cache-first approach to a network-first one.)
event.respondWith(
caches.open(`offline${timestamp}`).then(async cache => {
try {
const response = await fetch(event.request)
cache.put(event.request, response.clone())
return response
} catch (err) {
const response = await cache.match(event.request)
if (response) return response
throw err
}
})
)
})

33
example/src/template.html Normal file
View File

@ -0,0 +1,33 @@
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1.0'>
<meta name='theme-color' content='#333333'>
%sapper.base%
<link rel='stylesheet' href='global.css'>
<link rel='manifest' href='manifest.json'>
<link rel='icon' type='image/png' href='favicon.png'>
<!-- Sapper generates a <style> tag containing critical CSS
for the current page. CSS for the rest of the app is
lazily loaded when it precaches secondary pages -->
%sapper.styles%
<!-- This contains the contents of the <svelte:head> component, if
the current page has one -->
%sapper.head%
</head>
<body>
<!-- The application will be rendered inside this element,
because `src/client.js` references it -->
<div id='sapper'>%sapper.html%</div>
<!-- Sapper creates a <script> tag containing `src/client.js`
and anything else it needs to hydrate the app and
initialise the router -->
%sapper.scripts%
</body>
</html>

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

36
example/static/global.css Normal file
View File

@ -0,0 +1,36 @@
body {
margin: 0;
font-family: Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
font-size: 14px;
line-height: 1.5;
color: #333;
}
h1, h2, h3, h4, h5, h6 {
margin: 0 0 0.5em 0;
font-weight: 400;
line-height: 1.2;
}
h1 {
font-size: 2em;
}
a {
color: inherit;
}
code {
font-family: menlo, inconsolata, monospace;
font-size: calc(1em - 2px);
color: #555;
background-color: #f0f0f0;
padding: 0.2em 0.4em;
border-radius: 2px;
}
@media (min-width: 400px) {
body {
font-size: 16px;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

BIN
example/static/logo-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
example/static/logo-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,20 @@
{
"background_color": "#ffffff",
"theme_color": "#333333",
"name": "TODO",
"short_name": "TODO",
"display": "minimal-ui",
"start_url": "/",
"icons": [
{
"src": "logo-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "logo-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

3646
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,19 @@
{
"name": "svelte-i18n",
<<<<<<< HEAD
"version": "2.1.0",
=======
"version": "1.1.2",
>>>>>>> master
"main": "dist/i18n.js",
"module": "dist/i18n.mjs",
"bin": {
"svelte-i18n": "dist/cli.js"
},
"license": "MIT",
"description": "Internationalization library for Svelte",
"author": "Christian Kaisermann <christian@kaisermann.me>",
"repository": "https://github.com/kaisermann",
"repository": "https://github.com/kaisermann/svelte-i18n",
"keywords": [
"svelte",
"i18n",
@ -14,67 +21,82 @@
"localization",
"translation"
],
"engines": {
"node": ">= 11.15.0"
},
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"pretest": "npm run build",
<<<<<<< HEAD
"test": "jest",
"test:ci": "jest --silent",
"test:watch": "jest --verbose --watchAll",
"lint": "eslint \"src/**/*.ts\"",
"format": "prettier --loglevel silent --write \"src/**/*.ts\" && eslint --fix \"src/**/*.ts\"",
"prerelease": "conventional-changelog -p angular -i CHANGELOG.md -s -r 1 && git add CHANGELOG.md",
"release": " git add package.json && git commit -m \"chore(release): v$npm_package_version :tada:\"",
"postrelease": "git tag -a v$npm_package_version -m 'Release v$npm_package_version'",
"prepublishOnly": "run-s test:ci build release"
=======
"test": "jest --no-cache --verbose",
"test:watch": "jest --no-cache --verbose --watchAll",
"test:ci": "jest --no-cache --silent",
"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"
>>>>>>> master
},
"files": [
"dist/",
"src/"
"dist/"
],
"jest": {
"verbose": true,
"testURL": "http://localhost/",
"collectCoverage": true,
"testMatch": [
"<rootDir>/test/**/*.test.ts"
],
"collectCoverageFrom": [
"<rootDir>/src/**/*.ts"
],
"transform": {
"^.+\\.jsx?$": "babel-jest"
},
"testRegex": "(/test/.*|\\.(test|spec))\\.js$",
"moduleFileExtensions": [
"js"
],
"coveragePathIgnorePatterns": [
"/node_modules/",
"/test/"
],
"coverageThreshold": {
"global": {
"branches": 90,
"functions": 95,
"lines": 95,
"statements": 95
}
},
"collectCoverage": false
"^.+\\.tsx?$": "ts-jest"
}
},
"peerDependencies": {
"svelte": "^3.14.1"
},
"devDependencies": {
"@babel/core": "^7.7.2",
"@babel/preset-env": "^7.7.1",
"@types/estree": "0.0.39",
"@types/intl": "^1.2.0",
"@types/jest": "^24.0.23",
"babel-core": "^7.0.0-bridge.0",
"babel-jest": "^24.9.0",
"conventional-changelog-cli": "^2.0.28",
"eslint": "^6.6.0",
"eslint-config-kaisermann": "0.0.3",
"jest": "^24.9.0",
"npm-run-all": "^4.1.5",
"prettier": "^1.19.1",
"rollup": "^1.26.5",
"rollup-plugin-auto-external": "^2.0.0",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^5.1.2",
"svelte": "^3.14.0"
},
"peerDependencies": {
"svelte": "^3.14.0"
"rollup-plugin-typescript2": "^0.25.2",
"sass": "^1.23.6",
"svelte": "^3.14.1",
"svelte-preprocess": "^3.2.6",
"ts-jest": "^24.1.0",
"typescript": "^3.7.2"
},
"dependencies": {
"commander": "^4.0.1",
"deepmerge": "^4.2.2",
"dlv": "^1.1.3",
"estree-walker": "^0.9.0",
"fast-memoize": "^2.5.1",
"intl-messageformat": "^7.5.2",
"micro-memoize": "^4.0.8",
"object-resolve-path": "^1.1.1"
"tiny-glob": "^0.2.6"
}
}

View File

@ -1,19 +1,41 @@
import resolve from 'rollup-plugin-node-resolve'
import commonjs from 'rollup-plugin-commonjs'
import ts from 'rollup-plugin-typescript2'
import { terser } from 'rollup-plugin-terser'
import autoExternal from 'rollup-plugin-auto-external'
import pkg from './package.json'
const plugins = [resolve(), commonjs(), terser()]
const PROD = !process.env.ROLLUP_WATCH
export default [
{
input: 'src/index.js',
external: [...Object.keys(pkg.dependencies), 'svelte/store'],
input: 'src/client/index.ts',
external: [
...Object.keys(pkg.dependencies),
...Object.keys(pkg.peerDependencies),
'svelte/store',
],
output: [
{ file: pkg.module, format: 'es' },
{ file: pkg.main, format: 'cjs' },
],
plugins,
plugins: [commonjs(), autoExternal(), ts(), PROD && terser()],
},
{
input: 'src/cli/index.ts',
// external: id => {
// if (id.startsWith('/')) return false
// return externals.has(id) || id.match(/svelte/gi)
// },
output: [
{
file: pkg.bin['svelte-i18n'],
name: 'cli.js',
format: 'cjs',
banner: `#!/usr/bin/env node`,
},
],
plugins: [autoExternal(), commonjs(), ts(), PROD && terser()],
},
]

184
src/cli/extract.ts Normal file
View File

@ -0,0 +1,184 @@
import {
Node,
ObjectExpression,
ImportDeclaration,
ImportSpecifier,
CallExpression,
Identifier,
Literal,
} from 'estree'
import delve from 'dlv'
import { walk } from 'estree-walker'
import { Ast } from 'svelte/types/compiler/interfaces'
import { parse } from 'svelte/compiler'
import { deepSet } from './includes/deepSet'
import { getObjFromExpression } from './includes/getObjFromExpression'
import { Message } from './types'
const LIB_NAME = 'svelte-i18n'
const DEFINE_MESSAGES_METHOD_NAME = 'defineMessages'
const FORMAT_METHOD_NAMES = new Set(['format', '_', 't'])
const IGNORED_UTILITIES = new Set(['number', 'date', 'time'])
function isFormatCall(node: Node, imports: Set<string>) {
if (node.type !== 'CallExpression') return false
let identifier: Identifier
if (
node.callee.type === 'MemberExpression' &&
node.callee.property.type === 'Identifier' &&
!IGNORED_UTILITIES.has(node.callee.property.name)
) {
identifier = node.callee.object as Identifier
} else if (node.callee.type === 'Identifier') {
identifier = node.callee
}
if (!identifier || identifier.type !== 'Identifier') {
return false
}
const methodName = identifier.name.slice(1)
return imports.has(methodName)
}
function isMessagesDefinitionCall(node: Node, methodName: string) {
if (node.type !== 'CallExpression') return false
return (
node.callee &&
node.callee.type === 'Identifier' &&
node.callee.name === methodName
)
}
function getLibImportDeclarations(ast: Ast) {
return (ast.instance
? ast.instance.content.body.filter(
node =>
node.type === 'ImportDeclaration' && node.source.value === LIB_NAME
)
: []) as ImportDeclaration[]
}
function getDefineMessagesSpecifier(decl: ImportDeclaration) {
return decl.specifiers.find(
spec =>
'imported' in spec && spec.imported.name === DEFINE_MESSAGES_METHOD_NAME
) as ImportSpecifier
}
function getFormatSpecifiers(decl: ImportDeclaration) {
return decl.specifiers.filter(
spec => 'imported' in spec && FORMAT_METHOD_NAMES.has(spec.imported.name)
) as ImportSpecifier[]
}
export function collectFormatCalls(ast: Ast) {
const importDecls = getLibImportDeclarations(ast)
if (importDecls.length === 0) return []
const imports = new Set(
importDecls.flatMap(decl =>
getFormatSpecifiers(decl).map(n => n.local.name)
)
)
if (imports.size === 0) return []
const calls: CallExpression[] = []
function enter(node: Node) {
if (isFormatCall(node, imports)) {
calls.push(node as CallExpression)
this.skip()
}
}
walk(ast.instance as any, { enter })
walk(ast.html as any, { enter })
return calls
}
export function collectMessageDefinitions(ast: Ast) {
const definitions: ObjectExpression[] = []
const defineImportDecl = getLibImportDeclarations(ast).find(
getDefineMessagesSpecifier
)
if (defineImportDecl == null) return []
const defineMethodName = getDefineMessagesSpecifier(defineImportDecl).local
.name
walk(ast.instance as any, {
enter(node: Node) {
if (isMessagesDefinitionCall(node, defineMethodName) === false) return
const [arg] = (node as CallExpression).arguments
if (arg.type === 'ObjectExpression') {
definitions.push(arg)
this.skip()
}
},
})
return definitions.flatMap(definitionDict =>
definitionDict.properties.map(
propNode => propNode.value as ObjectExpression
)
)
}
export function collectMessages(markup: string): Message[] {
const ast = parse(markup)
const calls = collectFormatCalls(ast)
const definitions = collectMessageDefinitions(ast)
return [
...definitions.map(definition => getObjFromExpression(definition)),
...calls.map(call => {
const [pathNode, options] = call.arguments
if (pathNode.type === 'ObjectExpression') {
return getObjFromExpression(pathNode)
}
const node = pathNode as Literal
const id = node.value as string
if (options && options.type === 'ObjectExpression') {
const messageObj = getObjFromExpression(options)
messageObj.meta.id = id
return messageObj
}
return { node, meta: { id } }
}),
].filter(Boolean)
}
export function extractMessages(
markup: string,
{ accumulator = {}, shallow = false, overwrite = false } = {} as any
) {
collectMessages(markup).forEach(message => {
let defaultValue = message.meta.default
if (typeof defaultValue === 'undefined') {
defaultValue = ''
}
if (shallow) {
if (overwrite === false && message.meta.id in accumulator) {
return
}
accumulator[message.meta.id] = defaultValue
} else {
if (
overwrite === false &&
typeof delve(accumulator, message.meta.id) !== 'undefined'
) {
return
}
deepSet(accumulator, message.meta.id, defaultValue)
}
})
return accumulator
}

View File

@ -0,0 +1,17 @@
const isNumberString = (n: string) => !Number.isNaN(parseInt(n))
export function deepSet(obj: any, path: string, value: any) {
const parts = path.replace(/\[(\w+)\]/gi, '.$1').split('.')
return parts.reduce((ref, part, i) => {
if (part in ref) return (ref = ref[part])
if (i < parts.length - 1) {
if (isNumberString(parts[i + 1])) {
return (ref = ref[part] = [])
}
return (ref = ref[part] = {})
}
return (ref[part] = value)
}, obj)
}

View File

@ -0,0 +1,20 @@
import { ObjectExpression, Property, Identifier } from 'estree'
import { Message } from '../types'
export function getObjFromExpression(exprNode: ObjectExpression) {
return exprNode.properties.reduce<Message>(
(acc, prop: Property) => {
// we only want primitives
if (
prop.value.type === 'Literal' &&
prop.value.value !== Object(prop.value.value)
) {
const key = (prop.key as Identifier).name as string
acc.meta[key] = prop.value.value
}
return acc
},
{ node: exprNode, meta: {} }
)
}

73
src/cli/index.ts Normal file
View File

@ -0,0 +1,73 @@
import fs from 'fs'
import { dirname, resolve } from 'path'
import program from 'commander'
import glob from 'tiny-glob'
import { preprocess } from 'svelte/compiler'
import { extractMessages } from './extract'
const { readFile, writeFile, mkdir, access } = fs.promises
const fileExists = (path: string) =>
access(path)
.then(() => true)
.catch(() => false)
program
.command('extract <glob> [output]')
.description('extract all message definitions from files to a json')
.option(
'-s, --shallow',
'extract to a shallow dictionary (ids with dots interpreted as strings, not paths)',
false
)
.option(
'--overwrite',
'overwrite the content of the output file instead of just appending new properties',
false
)
.option(
'-c, --config <dir>',
'path to the "svelte.config.js" file',
process.cwd()
)
.action(async (globStr, output, { shallow, overwrite, config }) => {
const filesToExtract = (await glob(globStr)).filter(file =>
file.match(/\.html|svelte$/i)
)
const svelteConfig = await import(
resolve(config, 'svelte.config.js')
).catch(() => null)
let accumulator = {}
if (output != null && overwrite === false && (await fileExists(output))) {
accumulator = await readFile(output)
.then(file => JSON.parse(file.toString()))
.catch((e: Error) => {
console.warn(e)
accumulator = {}
})
}
for await (const filePath of filesToExtract) {
const buffer = await readFile(filePath)
let content = buffer.toString()
if (svelteConfig && svelteConfig.preprocess) {
const processed = await preprocess(content, svelteConfig.preprocess, {
filename: filePath,
})
content = processed.code
}
extractMessages(content, { accumulator, shallow })
}
const jsonDictionary = JSON.stringify(accumulator, null, ' ')
if (output == null) return console.log(jsonDictionary)
await mkdir(dirname(output), { recursive: true })
await writeFile(output, jsonDictionary)
})
program.parse(process.argv)

10
src/cli/types/index.ts Normal file
View File

@ -0,0 +1,10 @@
import { Node } from 'estree'
export interface Message {
node: Node
meta: {
id?: string
default?: string
[key: string]: any
}
}

87
src/client/configs.ts Normal file
View File

@ -0,0 +1,87 @@
import { getClientLocale } from './includes/utils'
import { ConfigureOptions } from './types'
import { $locale } from './stores/locale'
interface Formats {
number: Record<string, any>
date: Record<string, any>
time: Record<string, any>
}
interface Options {
fallbackLocale: string
initialLocale: string
formats: Formats
loadingDelay: number
warnOnMissingMessages: boolean
}
export const defaultFormats: Formats = {
number: {
scientific: { notation: 'scientific' },
engineering: { notation: 'engineering' },
compactLong: { notation: 'compact', compactDisplay: 'long' },
compactShort: { notation: 'compact', compactDisplay: 'short' },
},
date: {
short: { month: 'numeric', day: 'numeric', year: '2-digit' },
medium: { month: 'short', day: 'numeric', year: 'numeric' },
long: { month: 'long', day: 'numeric', year: 'numeric' },
full: { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' },
},
time: {
short: { hour: 'numeric', minute: 'numeric' },
medium: { hour: 'numeric', minute: 'numeric', second: 'numeric' },
long: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short',
},
full: {
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
timeZoneName: 'short',
},
},
}
export const defaultOptions: Options = {
fallbackLocale: null,
initialLocale: null,
loadingDelay: 200,
formats: defaultFormats,
warnOnMissingMessages: true,
}
const options: Options = defaultOptions
export function getOptions() {
return options
}
export function init(opts: ConfigureOptions) {
const { formats, ...rest } = opts
const initialLocale = opts.initialLocale
? typeof opts.initialLocale === 'string'
? opts.initialLocale
: getClientLocale(opts.initialLocale) || opts.fallbackLocale
: opts.fallbackLocale
Object.assign(options, rest, { initialLocale })
if (formats) {
if ('number' in formats) {
Object.assign(options.formats.number, formats.number)
}
if ('date' in formats) {
Object.assign(options.formats.date, formats.date)
}
if ('time' in formats) {
Object.assign(options.formats.time, formats.time)
}
}
return $locale.set(initialLocale)
}

View File

@ -0,0 +1,75 @@
import IntlMessageFormat from 'intl-messageformat'
import memoize from 'fast-memoize'
import { MemoizedIntlFormatter } from '../types'
import { getCurrentLocale } from '../stores/locale'
import { getOptions } from '../configs'
const getIntlFormatterOptions = (
type: 'time' | 'number' | 'date',
name: string
): any => {
const formats = getOptions().formats
if (type in formats && name in formats[type]) {
return formats[type][name]
}
throw new Error(`[svelte-i18n] Unknown "${name}" ${type} format.`)
}
export const getNumberFormatter: MemoizedIntlFormatter<
Intl.NumberFormat,
Intl.NumberFormatOptions
> = memoize(({ locale, format, ...options } = {}) => {
locale = locale || getCurrentLocale()
if (locale == null) {
throw new Error('[svelte-i18n] A "locale" must be set to format numbers')
}
if (format) {
options = getIntlFormatterOptions('number', format)
}
return new Intl.NumberFormat(locale, options)
})
export const getDateFormatter: MemoizedIntlFormatter<
Intl.DateTimeFormat,
Intl.DateTimeFormatOptions
> = memoize(({ locale, format, ...options } = {}) => {
locale = locale || getCurrentLocale()
if (locale == null) {
throw new Error('[svelte-i18n] A "locale" must be set to format dates')
}
if (format) options = getIntlFormatterOptions('date', format)
else if (Object.keys(options).length === 0) {
options = getIntlFormatterOptions('date', 'short')
}
return new Intl.DateTimeFormat(locale, options)
})
export const getTimeFormatter: MemoizedIntlFormatter<
Intl.DateTimeFormat,
Intl.DateTimeFormatOptions
> = memoize(({ locale, format, ...options } = {}) => {
locale = locale || getCurrentLocale()
if (locale == null) {
throw new Error(
'[svelte-i18n] A "locale" must be set to format time values'
)
}
if (format) options = getIntlFormatterOptions('time', format)
else if (Object.keys(options).length === 0) {
options = getIntlFormatterOptions('time', 'short')
}
return new Intl.DateTimeFormat(locale, options)
})
export const getMessageFormatter = memoize(
(message: string, locale: string) =>
new IntlMessageFormat(message, locale, getOptions().formats)
)

View File

@ -0,0 +1,96 @@
import { MessagesLoader } from '../types'
import {
hasLocaleDictionary,
$dictionary,
addMessages,
} from '../stores/dictionary'
import { getRelatedLocalesOf } from '../stores/locale'
import { $isLoading } from '../stores/loading'
import { getOptions } from '../configs'
type Queue = Set<MessagesLoader>
const loaderQueue: Record<string, Queue> = {}
export function resetQueues() {
Object.keys(loaderQueue).forEach(key => {
delete loaderQueue[key]
})
}
function createLocaleQueue(locale: string) {
loaderQueue[locale] = new Set()
}
function removeLocaleFromQueue(locale: string) {
delete loaderQueue[locale]
}
function getLocaleQueue(locale: string) {
return loaderQueue[locale]
}
function getLocalesQueues(locale: string) {
return getRelatedLocalesOf(locale)
.reverse()
.map<[string, MessagesLoader[]]>(localeItem => {
const queue = getLocaleQueue(localeItem)
return [localeItem, queue ? [...queue] : []]
})
.filter(([, queue]) => queue.length > 0)
}
export function hasLocaleQueue(locale: string) {
return getRelatedLocalesOf(locale)
.reverse()
.some(getLocaleQueue)
}
const activeLocaleFlushes: { [key: string]: Promise<void> } = {}
export function flush(locale: string) {
if (!hasLocaleQueue(locale)) return
if (locale in activeLocaleFlushes) return activeLocaleFlushes[locale]
// get queue of XX-YY and XX locales
const queues = getLocalesQueues(locale)
// istanbul ignore if
if (queues.length === 0) return
const loadingDelay = setTimeout(
() => $isLoading.set(true),
getOptions().loadingDelay
)
// TODO what happens if some loader fails
activeLocaleFlushes[locale] = Promise.all(
queues.map(([locale, queue]) => {
return Promise.all(queue.map(loader => loader())).then(partials => {
removeLocaleFromQueue(locale)
partials = partials.map(partial => partial.default || partial)
addMessages(locale, ...partials)
})
})
).then(() => {
clearTimeout(loadingDelay)
$isLoading.set(false)
delete activeLocaleFlushes[locale]
})
return activeLocaleFlushes[locale]
}
export function registerLocaleLoader(locale: string, loader: MessagesLoader) {
if (!getLocaleQueue(locale)) createLocaleQueue(locale)
const queue = getLocaleQueue(locale)
// istanbul ignore if
if (getLocaleQueue(locale).has(loader)) return
if (!hasLocaleDictionary(locale)) {
$dictionary.update(d => {
d[locale] = {}
return d
})
}
queue.add(loader)
}

View File

@ -0,0 +1,23 @@
import { getMessageFromDictionary } from '../stores/dictionary'
import { getFallbackOf } from '../stores/locale'
export const lookupCache: Record<string, Record<string, string>> = {}
const addToCache = (path: string, locale: string, message: string) => {
if (!message) return message
if (!(locale in lookupCache)) lookupCache[locale] = {}
if (!(path in lookupCache[locale])) lookupCache[locale][path] = message
return message
}
export const lookupMessage = (path: string, locale: string): string => {
if (locale == null) return null
if (locale in lookupCache && path in lookupCache[locale]) {
return lookupCache[locale][path]
}
const message = getMessageFromDictionary(locale, path)
if (message) return message
return addToCache(path, locale, lookupMessage(path, getFallbackOf(locale)))
}

View File

@ -0,0 +1,75 @@
import { GetClientLocaleOptions } from '../types'
export function capital(str: string) {
return str.replace(/(^|\s)\S/, l => l.toLocaleUpperCase())
}
export function title(str: string) {
return str.replace(/(^|\s)\S/g, l => l.toLocaleUpperCase())
}
export function upper(str: string) {
return str.toLocaleUpperCase()
}
export function lower(str: string) {
return str.toLocaleLowerCase()
}
const getFromQueryString = (queryString: string, key: string) => {
const keyVal = queryString.split('&').find(i => i.indexOf(`${key}=`) === 0)
if (keyVal) {
return keyVal.split('=').pop()
}
return null
}
const getFirstMatch = (base: string, pattern: RegExp) => {
const match = pattern.exec(base)
// istanbul ignore if
if (!match) return null
// istanbul ignore else
return match[1] || null
}
export const getClientLocale = ({
navigator,
hash,
search,
pathname,
hostname,
}: GetClientLocaleOptions) => {
let locale
// istanbul ignore next
if (typeof window === 'undefined') return null
if (hostname) {
locale = getFirstMatch(window.location.hostname, hostname)
if (locale) return locale
}
if (pathname) {
locale = getFirstMatch(window.location.pathname, pathname)
if (locale) return locale
}
if (navigator) {
// istanbul ignore else
locale = window.navigator.language || window.navigator.languages[0]
if (locale) return locale
}
if (search) {
locale = getFromQueryString(window.location.search.substr(1), search)
if (locale) return locale
}
if (hash) {
locale = getFromQueryString(window.location.hash.substr(1), hash)
if (locale) return locale
}
return null
}

26
src/client/index.ts Normal file
View File

@ -0,0 +1,26 @@
import { MessageObject } from './types'
import { getCurrentLocale } from './stores/locale'
import { getOptions } from './configs'
import { flush } from './includes/loaderQueue'
// defineMessages allow us to define and extract dynamic message ids
export function defineMessages(i: Record<string, MessageObject>) {
return i
}
export function waitLocale(locale?: string) {
return flush(locale || getCurrentLocale() || getOptions().initialLocale)
}
export { init } from './configs'
export { $locale as locale } from './stores/locale'
export {
$dictionary as dictionary,
$locales as locales,
addMessages,
} from './stores/dictionary'
export { $isLoading as isLoading } from './stores/loading'
export { $format as format, $format as _, $format as t } from './stores/format'
// utilities
export { registerLocaleLoader as register } from './includes/loaderQueue'

View File

@ -0,0 +1,56 @@
import delve from 'dlv'
import merge from 'deepmerge'
import { writable, derived } from 'svelte/store'
import { Dictionary } from '../types/index'
import { getFallbackOf } from './locale'
let dictionary: Dictionary
const $dictionary = writable<Dictionary>({})
export function getLocaleDictionary(locale: string) {
return (dictionary[locale] as Dictionary) || null
}
export function getDictionary() {
return dictionary
}
export function hasLocaleDictionary(locale: string) {
return locale in dictionary
}
export function getMessageFromDictionary(locale: string, id: string) {
if (hasLocaleDictionary(locale)) {
const localeDictionary = getLocaleDictionary(locale)
if (id in localeDictionary) {
return localeDictionary[id]
}
const message = delve(localeDictionary, id)
if (message) return message
}
return null
}
export function getClosestAvailableLocale(locale: string): string | null {
if (locale == null || hasLocaleDictionary(locale)) return locale
return getClosestAvailableLocale(getFallbackOf(locale))
}
export function addMessages(locale: string, ...partials: Dictionary[]) {
$dictionary.update(d => {
dictionary[locale] = merge.all<Dictionary>(
[getLocaleDictionary(locale) || {}].concat(partials)
)
return d
})
}
const $locales = derived([$dictionary], ([$dictionary]) =>
Object.keys($dictionary)
)
$dictionary.subscribe(newDictionary => (dictionary = newDictionary))
export { $dictionary, $locales }

View File

@ -0,0 +1,65 @@
import { derived } from 'svelte/store'
import { Formatter, MessageObject } from '../types'
import { lookupMessage } from '../includes/lookup'
import { hasLocaleQueue } from '../includes/loaderQueue'
import { capital, upper, lower, title } from '../includes/utils'
import {
getMessageFormatter,
getTimeFormatter,
getDateFormatter,
getNumberFormatter,
} from '../includes/formatters'
import { getOptions } from '../configs'
import { $dictionary } from './dictionary'
import { getCurrentLocale, getRelatedLocalesOf, $locale } from './locale'
const formatMessage: Formatter = (id, options = {}) => {
if (typeof id === 'object') {
options = id as MessageObject
id = options.id
}
const { values, locale = getCurrentLocale(), default: defaultValue } = options
if (locale == null) {
throw new Error(
'[svelte-i18n] Cannot format a message without first setting the initial locale.'
)
}
const message = lookupMessage(id, locale)
if (!message) {
if (getOptions().warnOnMissingMessages) {
// istanbul ignore next
console.warn(
`[svelte-i18n] The message "${id}" was not found in "${getRelatedLocalesOf(
locale
).join('", "')}".${
hasLocaleQueue(getCurrentLocale())
? `\n\nNote: there are at least one loader still registered to this locale that wasn't executed.`
: ''
}`
)
}
return defaultValue || id
}
if (!values) return message
return getMessageFormatter(message, locale).format(values)
}
formatMessage.time = (t, options) => getTimeFormatter(options).format(t)
formatMessage.date = (d, options) => getDateFormatter(options).format(d)
formatMessage.number = (n, options) => getNumberFormatter(options).format(n)
formatMessage.capital = (id, options) => capital(formatMessage(id, options))
formatMessage.title = (id, options) => title(formatMessage(id, options))
formatMessage.upper = (id, options) => upper(formatMessage(id, options))
formatMessage.lower = (id, options) => lower(formatMessage(id, options))
const $format = derived([$locale, $dictionary], () => formatMessage)
export { $format }

View File

@ -0,0 +1,3 @@
import { writable } from 'svelte/store'
export const $isLoading = writable(false)

View File

@ -0,0 +1,70 @@
import { writable } from 'svelte/store'
import { flush, hasLocaleQueue } from '../includes/loaderQueue'
import { getOptions } from '../configs'
import { getClosestAvailableLocale } from './dictionary'
let current: string
const $locale = writable(null)
export function isFallbackLocaleOf(localeA: string, localeB: string) {
return localeB.indexOf(localeA) === 0 && localeA !== localeB
}
export function isRelatedLocale(localeA: string, localeB: string) {
return (
localeA === localeB ||
isFallbackLocaleOf(localeA, localeB) ||
isFallbackLocaleOf(localeB, localeA)
)
}
export function getFallbackOf(locale: string) {
const index = locale.lastIndexOf('-')
if (index > 0) return locale.slice(0, index)
const { fallbackLocale } = getOptions()
if (fallbackLocale && !isRelatedLocale(locale, fallbackLocale)) {
return fallbackLocale
}
return null
}
export function getRelatedLocalesOf(locale: string): string[] {
const locales = locale
.split('-')
.map((_, i, arr) => arr.slice(0, i + 1).join('-'))
const { fallbackLocale } = getOptions()
if (fallbackLocale && !isRelatedLocale(locale, fallbackLocale)) {
return locales.concat(getRelatedLocalesOf(fallbackLocale))
}
return locales
}
export function getCurrentLocale() {
return current
}
$locale.subscribe((newLocale: string) => {
current = newLocale
if (typeof window !== 'undefined') {
document.documentElement.setAttribute('lang', newLocale)
}
})
const localeSet = $locale.set
$locale.set = (newLocale: string): void | Promise<void> => {
if (getClosestAvailableLocale(newLocale) && hasLocaleQueue(newLocale)) {
return flush(newLocale).then(() => localeSet(newLocale))
}
return localeSet(newLocale)
}
// istanbul ignore next
$locale.update = (fn: (locale: string) => void | Promise<void>) =>
localeSet(fn(current))
export { $locale }

64
src/client/types/index.ts Normal file
View File

@ -0,0 +1,64 @@
import { Formats } from 'intl-messageformat'
export interface Dictionary {
[key: string]: string | string[] | Dictionary | Dictionary[]
}
export interface MessageObject {
id?: string
locale?: string
format?: string
default?: string
values?: Record<string, string | number | Date>
}
interface FormatterFn {
(id: string | MessageObject, options?: MessageObject): string
}
type IntlFormatterOptions<T> = T & {
format?: string
locale?: string
}
export interface MemoizedIntlFormatter<T, U> {
(options?: IntlFormatterOptions<U>): T
}
export interface Formatter extends FormatterFn {
time: (
d: Date | number,
options?: IntlFormatterOptions<Intl.DateTimeFormatOptions>
) => string
date: (
d: Date | number,
options?: IntlFormatterOptions<Intl.DateTimeFormatOptions>
) => string
number: (
d: number,
options?: IntlFormatterOptions<Intl.NumberFormatOptions>
) => string
capital: FormatterFn
title: FormatterFn
upper: FormatterFn
lower: FormatterFn
}
export interface MessagesLoader {
(): Promise<any>
}
export interface GetClientLocaleOptions {
navigator?: boolean
hash?: string
search?: string
pathname?: RegExp
hostname?: RegExp
}
export interface ConfigureOptions {
fallbackLocale: string
initialLocale?: string | GetClientLocaleOptions
formats?: Partial<Formats>
loadingDelay?: number
}

2
src/client/types/modules.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module 'dlv'
declare module 'nano-memoize'

View File

@ -1,119 +0,0 @@
import { writable, derived } from 'svelte/store/index.js'
import resolvePath from 'object-resolve-path'
import IntlMessageFormat from 'intl-messageformat'
import memoize from 'micro-memoize'
import { capital, title, upper, lower, getClientLocale } from './utils.js'
let currentLocale
let currentDictionary
const customFormats = {
number: {
scientific: { notation: 'scientific' },
engineering: { notation: 'engineering' },
compactLong: { notation: 'compact', compactDisplay: 'long' },
compactShort: { notation: 'compact', compactDisplay: 'short' },
},
}
function addCustomFormats(formats) {
if ('number' in formats) Object.assign(customFormats.number, formats.number)
if ('date' in formats) Object.assign(customFormats.date, formats.date)
if ('time' in formats) Object.assign(customFormats.time, formats.time)
}
function getAvailableLocale(newLocale) {
if (currentDictionary[newLocale]) return newLocale
// istanbul ignore else
if (typeof newLocale === 'string') {
const fallbackLocale = newLocale.split('-').shift()
if (currentDictionary[fallbackLocale]) {
return fallbackLocale
}
}
return null
}
const getMessageFormatter = memoize(
(message, locale) => new IntlMessageFormat(message, locale, customFormats),
)
const lookupMessage = memoize((path, locale) => {
return (
currentDictionary[locale][path] ||
resolvePath(currentDictionary[locale], path)
)
})
function formatString(string, { values, locale = currentLocale } = {}) {
return getMessageFormatter(string, locale).format(values)
}
function formatMessage(path, { values, locale = currentLocale } = {}) {
const message = lookupMessage(path, locale)
if (!message) {
console.warn(
`[svelte-i18n] The message "${path}" was not found in the locale "${locale}".`,
)
return path
}
if (!values) return message
return getMessageFormatter(message, locale).format(values)
}
formatMessage.time = (t, { format = 'short' } = {}) =>
formatString(`{t,time,${format}}`, { values: { t } })
formatMessage.date = (d, { format = 'short' } = {}) =>
formatString(`{d,date,${format}}`, { values: { d } })
formatMessage.number = (n, { format } = {}) =>
formatString(`{n,number,${format}}`, { values: { n } })
formatMessage.capital = (path, options) => capital(formatMessage(path, options))
formatMessage.title = (path, options) => title(formatMessage(path, options))
formatMessage.upper = (path, options) => upper(formatMessage(path, options))
formatMessage.lower = (path, options) => lower(formatMessage(path, options))
const dictionary = writable({})
dictionary.subscribe(newDictionary => {
currentDictionary = newDictionary
})
const locale = writable({})
const localeSet = locale.set
locale.set = newLocale => {
const availableLocale = getAvailableLocale(newLocale)
if (availableLocale) {
return localeSet(availableLocale)
}
throw Error(`[svelte-i18n] Locale "${newLocale}" not found.`)
}
locale.update = fn => localeSet(fn(currentLocale))
locale.subscribe(newLocale => {
currentLocale = newLocale
})
const format = derived([locale, dictionary], () => formatMessage)
export {
locale,
format as _,
format,
formatString,
dictionary,
getClientLocale,
customFormats,
addCustomFormats,
}

View File

@ -1,37 +0,0 @@
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 getClientLocale = ({ navigator, hash, search, fallback } = {}) => {
let locale
const getFromURL = (urlPart, key) => {
const keyVal = urlPart
.substr(1)
.split('&')
.find(i => i.indexOf(key) === 0)
if (keyVal) {
return keyVal.split('=').pop()
}
}
// istanbul ignore else
if (typeof window !== 'undefined') {
if (navigator) {
// istanbul ignore next
locale = window.navigator.language || window.navigator.languages[0]
}
if (search) {
locale = getFromURL(window.location.search, search)
}
if (hash) {
locale = getFromURL(window.location.hash, hash)
}
}
return locale || fallback
}

267
test/cli/extract.test.ts Normal file
View File

@ -0,0 +1,267 @@
// TODO: better tests. these are way too generic.
import { parse } from 'svelte/compiler'
import {
collectFormatCalls,
collectMessageDefinitions,
collectMessages,
extractMessages,
} from '../../src/cli/extract'
describe('collecting format calls', () => {
test('returns nothing if there are no script tag', () => {
const ast = parse(`<div>Hey</div>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(0)
})
test('returns nothing if there are no imports', () => {
const ast = parse(`<script>
import Foo from 'foo';
const $_ = () => 0; $_();
</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(0)
})
test('returns nothing if there are no format imports', () => {
const ast = parse(
`<script>
import { init } from 'svelte-i18n';
init({})
</script>`
)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(0)
})
test('collects all format calls in the instance script', () => {
const ast = parse(`<script>
import { format, _ } from 'svelte-i18n'
$format('foo')
format('bar')
let label = $_({id:'bar'})
const a = { b: () => 0}
</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({ type: 'CallExpression' })
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
})
test('collects all format calls with renamed imports', () => {
const ast = parse(`<script>
import { format as _x, _ as intl, t as f } from 'svelte-i18n'
$_x('foo')
$intl({ id: 'bar' })
$f({ id: 'bar' })
</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(3)
expect(calls[0]).toMatchObject({ type: 'CallExpression' })
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
expect(calls[2]).toMatchObject({ type: 'CallExpression' })
})
test('collects all string format utility calls', () => {
const ast = parse(`<script>
import { _ } from 'svelte-i18n'
$_.title('foo')
$_.capitalize({ id: 'bar' })
$_.lower({ id: 'bar' })
$_.upper({ id: 'bar' })
</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(4)
expect(calls[0]).toMatchObject({ type: 'CallExpression' })
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
expect(calls[2]).toMatchObject({ type: 'CallExpression' })
expect(calls[3]).toMatchObject({ type: 'CallExpression' })
})
test('ignores date, time and number calls', () => {
const ast = parse(`<script>
import { _ } from 'svelte-i18n'
$_.number(1000)
$_.date(new Date())
$_.time(new Date())
</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(0)
})
})
describe('collecting message definitions', () => {
test('returns nothing if there are no imports from the library', () => {
const ast = parse(
`<script>
import foo from 'foo';
import { dictionary } from 'svelte-i18n';
</script>`
)
expect(collectMessageDefinitions(ast)).toHaveLength(0)
})
test('gets all message definition objects', () => {
const ast = parse(`<script>
import { defineMessages } from 'svelte-i18n';
defineMessages({ foo: { id: 'foo' }, bar: { id: 'bar' } })
defineMessages({ baz: { id: 'baz' }, quix: { id: 'qux' } })
</script>`)
const definitions = collectMessageDefinitions(ast)
expect(definitions).toHaveLength(4)
expect(definitions[0]).toMatchObject({ type: 'ObjectExpression' })
expect(definitions[1]).toMatchObject({ type: 'ObjectExpression' })
expect(definitions[2]).toMatchObject({ type: 'ObjectExpression' })
expect(definitions[3]).toMatchObject({ type: 'ObjectExpression' })
})
})
describe('collecting messages', () => {
test('collects all messages in both instance and html ASTs', () => {
const markup = `
<script>
import { _, defineMessages } from 'svelte-i18n';
console.log($_({ id: 'foo' }))
console.log($_.title({ id: 'page.title' }))
const messages = defineMessages({
enabled: { id: 'enabled' , default: 'Enabled' },
disabled: { id: 'disabled', default: 'Disabled' }
})
</script>
<div>{$_('msg_1')}</div>
<div>{$_({id: 'msg_2'})}</div>
<div>{$_('msg_3', { default: 'Message'})}</div>`
const messages = collectMessages(markup)
expect(messages).toHaveLength(7)
expect(messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ meta: { id: 'foo' } }),
expect.objectContaining({ meta: { id: 'msg_1' } }),
expect.objectContaining({ meta: { id: 'msg_2' } }),
expect.objectContaining({ meta: { id: 'msg_3', default: 'Message' } }),
expect.objectContaining({ meta: { id: 'page.title' } }),
expect.objectContaining({
meta: { id: 'disabled', default: 'Disabled' },
}),
expect.objectContaining({
meta: { id: 'enabled', default: 'Enabled' },
}),
])
)
})
})
describe('messages extraction', () => {
test('returns a object built based on all found message paths', () => {
const markup = `<script>
import { _ } from 'svelte-i18n';
</script>
<h1>{$_.title('title')}</h1>
<h2>{$_({ id: 'subtitle'})}</h2>
`
const dict = extractMessages(markup)
expect(dict).toMatchObject({ title: '', subtitle: '' })
})
test('creates deep nested properties', () => {
const markup = `
<script>import { _ } from 'svelte-i18n';</script>
<h1>{$_.title('home.page.title')}</h1>
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
<ul>
<li>{$_('list.0')}</li>
<li>{$_('list.1')}</li>
<li>{$_('list.2')}</li>
</ul>
`
const dict = extractMessages(markup)
expect(dict).toMatchObject({
home: { page: { title: '', subtitle: '' } },
list: ['', '', ''],
})
})
test('creates a shallow dictionary', () => {
const markup = `
<script>import { _ } from 'svelte-i18n';</script>
<h1>{$_.title('home.page.title')}</h1>
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
<ul>
<li>{$_('list.0')}</li>
<li>{$_('list.1')}</li>
<li>{$_('list.2')}</li>
</ul>
`
const dict = extractMessages(markup, { shallow: true })
expect(dict).toMatchObject({
'home.page.title': '',
'home.page.subtitle': '',
'list.0': '',
'list.1': '',
'list.2': '',
})
})
test('allow to pass a initial dictionary and only append non-existing props', () => {
const markup = `
<script>import { _ } from 'svelte-i18n';</script>
<h1>{$_.title('home.page.title')}</h1>
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
<ul>
<li>{$_('list.0')}</li>
<li>{$_('list.1')}</li>
<li>{$_('list.2')}</li>
</ul>
`
const dict = extractMessages(markup, {
overwrite: false,
accumulator: {
home: {
page: {
title: 'Page title',
},
},
},
})
expect(dict).toMatchObject({
home: {
page: {
title: 'Page title',
subtitle: '',
},
},
list: ['', '', ''],
})
})
test('allow to pass a initial dictionary and only append shallow non-existing props', () => {
const markup = `
<script>import { _ } from 'svelte-i18n';</script>
<h1>{$_.title('home.page.title')}</h1>
<h2>{$_({ id: 'home.page.subtitle'})}</h2>
`
const dict = extractMessages(markup, {
overwrite: false,
shallow: true,
accumulator: {
'home.page.title': 'Page title',
},
})
expect(dict).toMatchObject({
'home.page.title': 'Page title',
'home.page.subtitle': '',
})
})
})

View File

@ -0,0 +1,65 @@
import { get } from 'svelte/store'
import {
init,
getOptions,
defaultOptions,
defaultFormats,
} from '../../src/client/configs'
import { $locale } from '../../src/client/stores/locale'
beforeEach(() => {
init(defaultOptions)
})
test('inits the fallback locale', () => {
expect(getOptions().fallbackLocale).toBe(null)
init({
fallbackLocale: 'en',
})
expect(getOptions().fallbackLocale).toBe('en')
})
test('inits the initial locale by string', () => {
init({
fallbackLocale: 'pt',
initialLocale: 'en',
})
expect(getOptions().initialLocale).toBe('en')
expect(get($locale)).toBe('en')
})
test('inits the initial locale by client heuristics', () => {
delete window.location
window.location = {
search: '?lang=en-US&foo',
pathname: '/',
hostname: 'example.com',
hash: '',
} as any
init({
fallbackLocale: 'pt',
initialLocale: {
search: 'lang',
},
})
expect(getOptions().initialLocale).toBe('en-US')
expect(get($locale)).toBe('en-US')
})
test('adds custom formats for time, date and number values', () => {
const customFormats = require('../fixtures/formats.json')
init({
fallbackLocale: 'en',
formats: customFormats,
})
expect(getOptions().formats).toMatchObject(defaultFormats)
expect(getOptions().formats).toMatchObject(customFormats)
})
test('sets the minimum delay to set the loading store value', () => {
init({ fallbackLocale: 'en', loadingDelay: 300 })
expect(getOptions().loadingDelay).toBe(300)
})

View File

@ -0,0 +1,173 @@
import {
getNumberFormatter,
getDateFormatter,
getTimeFormatter,
getMessageFormatter,
} from '../../../src/client/includes/formatters'
import { init } from '../../../src/client/configs'
beforeEach(() => {
init({ fallbackLocale: undefined })
})
describe('number formatter', () => {
const number = 123123
test('throws if no locale is set', () => {
expect(() => getNumberFormatter().format(number)).toThrow(
'[svelte-i18n] A "locale" must be set to format numbers'
)
})
test('formats a date according to the current locale', () => {
init({ fallbackLocale: 'en' })
expect(getNumberFormatter().format(number)).toBe('123,123')
})
test('formats a number according to a locale', () => {
init({ fallbackLocale: 'en' })
expect(getNumberFormatter({ locale: 'pt-BR' }).format(number)).toBe(
'123.123'
)
})
test('formats a number with a custom format', () => {
init({
fallbackLocale: 'en',
formats: require('../../fixtures/formats.json'),
})
expect(getNumberFormatter({ format: 'brl' }).format(number)).toBe(
'R$123,123.00'
)
})
test('formats a number with inline options', () => {
init({ fallbackLocale: 'en' })
expect(
getNumberFormatter({ style: 'currency', currency: 'BRL' }).format(number)
).toBe('R$123,123.00')
})
})
describe('date formatter', () => {
const date = new Date(2019, 1, 1)
test('throws if no locale is set', () => {
expect(() => getDateFormatter().format(date)).toThrow(
'[svelte-i18n] A "locale" must be set to format dates'
)
})
test('formats a date according to the current locale', () => {
init({ fallbackLocale: 'en' })
expect(getDateFormatter().format(date)).toBe('2/1/19')
})
test('formats a date according to a locale', () => {
expect(getDateFormatter({ locale: 'pt-BR' }).format(date)).toBe('01/02/19')
})
test('throws if passed a non-existing format', () => {
init({
fallbackLocale: 'en',
formats: require('../../fixtures/formats.json'),
})
expect(() =>
getDateFormatter({ locale: 'pt-BR', format: 'foo' }).format(date)
).toThrowError(`[svelte-i18n] Unknown "foo" date format.`)
})
test('formats a date with a custom format', () => {
init({
fallbackLocale: 'en',
formats: require('../../fixtures/formats.json'),
})
expect(getDateFormatter({ format: 'customDate' }).format(date)).toBe(
'2019 AD'
)
})
test('formats a date with inline options', () => {
init({ fallbackLocale: 'en' })
expect(
getDateFormatter({ year: 'numeric', era: 'short' }).format(date)
).toBe('2019 AD')
})
})
describe('time formatter', () => {
const time = new Date(2019, 1, 1, 20, 37, 32)
test('throws if no locale is set', () => {
expect(() => getTimeFormatter().format(time)).toThrow(
'[svelte-i18n] A "locale" must be set to format time'
)
})
test('formats a time according to the current locale', () => {
init({ fallbackLocale: 'en' })
expect(getTimeFormatter().format(time)).toBe('8:37 PM')
})
test('formats a time according to a locale', () => {
expect(getTimeFormatter({ locale: 'pt-BR' }).format(time)).toBe('20:37')
})
test('formats a time with a custom format', () => {
init({
fallbackLocale: 'en',
formats: require('../../fixtures/formats.json'),
})
expect(getTimeFormatter({ format: 'customTime' }).format(time)).toBe(
'08:37:32 PM'
)
})
test('throws if passed a non-existing format', () => {
init({
fallbackLocale: 'en',
formats: require('../../fixtures/formats.json'),
})
expect(() =>
getTimeFormatter({ locale: 'pt-BR', format: 'foo' }).format(time)
).toThrowError(`[svelte-i18n] Unknown "foo" time format.`)
})
test('formats a time with inline options', () => {
init({ fallbackLocale: 'en' })
expect(
getTimeFormatter({
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(time)
).toBe('08:37:32 PM')
})
})
describe('message formatter', () => {
test('formats a message with interpolated values', () => {
expect(
getMessageFormatter('Page: {current,number}/{max,number}', 'en').format({
current: 2,
max: 10,
})
).toBe('Page: 2/10')
})
test('formats number with custom formats', () => {
expect(
getMessageFormatter('Number: {n, number, compactShort}', 'en').format({
n: 2000000,
})
).toBe('Number: 2M')
})
})

View File

@ -0,0 +1,83 @@
import { get } from 'svelte/store'
import {
hasLocaleQueue,
flush,
registerLocaleLoader,
resetQueues,
} from '../../../src/client/includes/loaderQueue'
import { getMessageFromDictionary } from '../../../src/client/stores/dictionary'
import { $isLoading } from '../../../src/client/stores/loading'
import { getOptions } from '../../../src/client/configs'
beforeEach(() => {
resetQueues()
})
const loader = (content: any) => () => new Promise(res => res(content))
test('registers a locale loader', () => {
expect(hasLocaleQueue('pt-BR')).toBe(false)
registerLocaleLoader('pt-BR', loader({ message: 'Mensagem' }))
expect(hasLocaleQueue('pt-BR')).toBe(true)
})
test('checks if exist queues of locale and its fallbacks', () => {
registerLocaleLoader('en', loader({ field: 'Name' }))
expect(hasLocaleQueue('en-US')).toBe(true)
})
test("does nothing if there's no queue for a locale", () => {
expect(flush('foo')).toBe(undefined)
})
test('flushes the queue of a locale and its fallbacks and merge the result with the dictionary', async () => {
registerLocaleLoader('en', loader({ field: 'Name' }))
registerLocaleLoader('en-US', loader({ field_2: 'Lastname' }))
await flush('en-US')
expect(getMessageFromDictionary('en', 'field')).toBe('Name')
expect(getMessageFromDictionary('en-US', 'field_2')).toBe('Lastname')
expect(hasLocaleQueue('en')).toBe(false)
expect(hasLocaleQueue('en-US')).toBe(false)
})
test('consecutive flushes return the same promise', async () => {
registerLocaleLoader('en', async () => ({}))
const flushA = flush('en')
const flushB = flush('en')
const flushC = flush('en')
expect(flushB).toStrictEqual(flushA)
expect(flushC).toStrictEqual(flushA)
})
test('should set loading to true if passed min delay and false after loading', () => {
registerLocaleLoader(
'en',
() =>
new Promise(res =>
setTimeout(() => res({}), getOptions().loadingDelay * 2)
)
)
const flushPromise = flush('en')
return new Promise((res, rej) => {
setTimeout(() => {
if (get($isLoading) === true) return res()
return rej('$isLoading should be "true"')
}, getOptions().loadingDelay)
}).then(() => {
flushPromise.then(
() =>
new Promise((res, rej) => {
if (get($isLoading) === false) return res()
return rej('$isLoading should be "false" after loading')
})
)
})
})

View File

@ -0,0 +1,48 @@
import { lookupMessage, lookupCache } from '../../../src/client/includes/lookup'
import { $dictionary, addMessages } from '../../../src/client/stores/dictionary'
beforeEach(() => {
$dictionary.set({})
})
test('returns null if no locale was passed', () => {
expect(lookupMessage('message.id', undefined)).toBe(null)
expect(lookupMessage('message.id', null)).toBe(null)
})
test('gets a shallow message of a locale dictionary', () => {
addMessages('en', { field: 'name' })
expect(lookupMessage('field', 'en')).toBe('name')
})
test('gets a deep message of a locale dictionary', () => {
addMessages('en', { deep: { field: 'lastname' } })
expect(lookupMessage('deep.field', 'en')).toBe('lastname')
})
test('gets a message from the fallback dictionary', () => {
addMessages('en', { field: 'name' })
expect(lookupMessage('field', 'en-US')).toBe('name')
})
test('caches found messages by locale', () => {
addMessages('en', { field: 'name' })
addMessages('pt', { field: 'nome' })
lookupMessage('field', 'en-US')
lookupMessage('field', 'pt-BR')
expect(lookupCache).toMatchObject({
'en-US': { field: 'name' },
'pt-BR': { field: 'nome' },
})
})
test("doesn't cache falsy messages", () => {
addMessages('en', { field: 'name' })
addMessages('pt', { field: 'nome' })
lookupMessage('field_2', 'en-US')
lookupMessage('field_2', 'pt-BR')
expect(lookupCache).not.toMatchObject({
'en-US': { field_2: 'name' },
'pt-BR': { field_2: 'nome' },
})
})

View File

@ -0,0 +1,131 @@
import {
getClientLocale,
capital,
title,
upper,
lower,
} from '../../../src/client/includes/utils'
describe('getting client locale', () => {
beforeEach(() => {
delete window.location
window.location = {
pathname: '/',
hostname: 'example.com',
hash: '',
search: '',
} as any
})
test('gets the locale based on the passed hash parameter', () => {
window.location.hash = '#locale=en-US&lang=pt-BR'
expect(
getClientLocale({
hash: 'lang',
})
).toBe('pt-BR')
})
test('gets the locale based on the passed search parameter', () => {
window.location.search = '?locale=en-US&lang=pt-BR'
expect(
getClientLocale({
search: 'lang',
})
).toBe('pt-BR')
})
test('gets the locale based on the navigator language', () => {
expect(
getClientLocale({
navigator: true,
})
).toBe(window.navigator.language)
})
test('gets the locale based on the pathname', () => {
window.location.pathname = '/en-US/foo/'
expect(
getClientLocale({
pathname: /^\/(.*?)\//,
})
).toBe('en-US')
})
test('gets the locale base on the hostname', () => {
window.location.hostname = 'pt.example.com'
expect(
getClientLocale({
hostname: /^(.*?)\./,
})
).toBe('pt')
})
test('hostname precedes pathname', () => {
window.location.pathname = '/en-US/foo/'
window.location.hostname = 'pt.example.com'
expect(
getClientLocale({
hostname: /^(.*?)\./,
pathname: /^\/(.*?)\//,
})
).toBe('pt')
})
test('pathname precedes navigator', () => {
window.location.pathname = '/it-IT/foo/'
expect(
getClientLocale({
pathname: /^\/(.*?)\//,
navigator: true,
})
).toBe('it-IT')
})
test('navigator precedes search', () => {
window.location.search = '?lang=pt-BR'
expect(
getClientLocale({
navigator: true,
search: 'lang',
})
).toBe('en-US')
})
test('search precedes hash', () => {
window.location.hash = '#lang=pt-BR'
window.location.search = '?lang=it-IT'
expect(
getClientLocale({
hash: 'lang',
search: 'lang',
})
).toBe('it-IT')
})
test('returns null if no locale was found', () => {
expect(
getClientLocale({
search: 'lang',
})
).toBe(null)
})
})
describe('string utilities', () => {
test('transforms a string into capital case', () => {
expect(capital('lowercase string')).toMatch('Lowercase string')
})
test('transforms a string into title case', () => {
expect(title('lowercase string')).toMatch('Lowercase String')
})
test('transforms a string into uppercase', () => {
expect(upper('lowercase string')).toMatch('LOWERCASE STRING')
})
test('transforms a string into lowercase', () => {
expect(lower('UPPERCASE STRING')).toMatch('uppercase string')
})
})

73
test/client/index.test.ts Normal file
View File

@ -0,0 +1,73 @@
import { defineMessages, waitLocale, register, init } from '../../src/client'
import { $locale } from '../../src/client/stores/locale'
import { hasLocaleQueue } from '../../src/client/includes/loaderQueue'
import {
getLocaleDictionary,
$dictionary,
} from '../../src/client/stores/dictionary'
import { $format } from '../../src/client/stores/format'
test('defineMessages returns the identity of its first argument', () => {
const obj = {}
expect(obj).toBe(defineMessages(obj))
})
describe('waiting for a locale to load', () => {
beforeEach(() => {
$dictionary.set({})
$locale.set(undefined)
})
test('should wait for a locale queue to be flushed', async () => {
register('en', () => Promise.resolve({ foo: 'foo' }))
$locale.set('en')
await waitLocale('en')
expect(hasLocaleQueue('en')).toBe(false)
expect(getLocaleDictionary('en')).toMatchObject({ foo: 'foo' })
})
test('should wait for the current locale queue to be flushed', async () => {
register('en', () => Promise.resolve({ foo: 'foo' }))
init({ fallbackLocale: 'pt', initialLocale: 'en' })
await waitLocale()
expect(hasLocaleQueue('en')).toBe(false)
expect(getLocaleDictionary('en')).toMatchObject({ foo: 'foo' })
})
test('should wait for the fallback locale queue to be flushed if initial not set', async () => {
register('pt', () => Promise.resolve({ foo: 'foo' }))
init({ fallbackLocale: 'pt' })
await waitLocale()
expect(hasLocaleQueue('pt')).toBe(false)
expect(getLocaleDictionary('pt')).toMatchObject({ foo: 'foo' })
})
})
describe('format updates', () => {
beforeEach(() => {
init({ fallbackLocale: 'en' })
})
test('format store is updated when locale changes', () => {
const fn = jest.fn()
const cancel = $format.subscribe(fn)
$locale.set('pt')
expect(fn).toHaveBeenCalledTimes(2)
cancel()
})
test('format store is updated when dictionary changes', () => {
const fn = jest.fn()
const cancel = $format.subscribe(fn)
$dictionary.set({})
expect(fn).toHaveBeenCalledTimes(2)
cancel()
})
})

View File

@ -0,0 +1,113 @@
import { get } from 'svelte/store'
import {
getDictionary,
hasLocaleDictionary,
getClosestAvailableLocale,
getMessageFromDictionary,
addMessages,
$dictionary,
$locales,
getLocaleDictionary,
} from '../../../src/client/stores/dictionary'
beforeEach(() => {
$dictionary.set({})
})
test('adds a new dictionary to a locale', () => {
addMessages('en', { field_1: 'name' })
addMessages('pt', { field_1: 'nome' })
expect(get($dictionary)).toMatchObject({
en: { field_1: 'name' },
pt: { field_1: 'nome' },
})
})
test('gets the whole current dictionary', () => {
addMessages('en', { field_1: 'name' })
expect(getDictionary()).toMatchObject(get($dictionary))
})
test('merges the existing dictionaries with new ones', () => {
addMessages('en', { field_1: 'name', deep: { prop1: 'foo' } })
addMessages('en', { field_2: 'lastname', deep: { prop2: 'foo' } })
addMessages('pt', { field_1: 'nome', deep: { prop1: 'foo' } })
addMessages('pt', { field_2: 'sobrenome', deep: { prop2: 'foo' } })
expect(get($dictionary)).toMatchObject({
en: {
field_1: 'name',
field_2: 'lastname',
deep: { prop1: 'foo', prop2: 'foo' },
},
pt: {
field_1: 'nome',
field_2: 'sobrenome',
deep: { prop1: 'foo', prop2: 'foo' },
},
})
})
test('gets the dictionary of a locale', () => {
addMessages('en', { field_1: 'name' })
expect(getLocaleDictionary('en')).toMatchObject({ field_1: 'name' })
})
test('checks if a locale dictionary exists', () => {
addMessages('pt', { field_1: 'name' })
expect(hasLocaleDictionary('en')).toBe(false)
expect(hasLocaleDictionary('pt')).toBe(true)
})
test('gets the closest available locale', () => {
addMessages('pt', { field_1: 'name' })
expect(getClosestAvailableLocale('pt-BR')).toBe('pt')
})
test("returns null if there's no closest locale available", () => {
addMessages('pt', { field_1: 'name' })
expect(getClosestAvailableLocale('it-IT')).toBe(null)
})
test('lists all locales in the dictionary', () => {
addMessages('en', {})
addMessages('pt', {})
addMessages('pt-BR', {})
expect(get($locales)).toEqual(['en', 'pt', 'pt-BR'])
})
describe('getting messages', () => {
test('gets a message from a shallow dictionary', () => {
addMessages('en', { message: 'Some message' })
expect(getMessageFromDictionary('en', 'message')).toBe('Some message')
})
test('gets a message from a deep object in the dictionary', () => {
addMessages('en', { messages: { message_1: 'Some message' } })
expect(getMessageFromDictionary('en', 'messages.message_1')).toBe(
'Some message'
)
})
test('gets a message from an array in the dictionary', () => {
addMessages('en', { messages: ['Some message', 'Other message'] })
expect(getMessageFromDictionary('en', 'messages.0')).toBe('Some message')
expect(getMessageFromDictionary('en', 'messages.1')).toBe('Other message')
})
test('accepts english in dictionary keys', () => {
addMessages('pt', {
'Hey man, how are you today?': 'E ai cara, como você vai hoje?',
})
expect(getMessageFromDictionary('pt', 'Hey man, how are you today?')).toBe(
'E ai cara, como você vai hoje?'
)
})
test('returns null for missing messages', () => {
addMessages('en', {})
expect(getMessageFromDictionary('en', 'foo')).toBe(null)
})
})

View File

@ -0,0 +1,92 @@
import { Formatter } from '../../../src/client/types/index'
import { $format } from '../../../src/client/stores/format'
import { init } from '../../../src/client/configs'
import { addMessages } from '../../../src/client/stores/dictionary'
import { $locale } from '../../../src/client/stores/locale'
let format: Formatter
$format.subscribe(f => (format = f))
addMessages('en', require('../../fixtures/en.json'))
addMessages('en-GB', require('../../fixtures/en-GB.json'))
addMessages('pt', require('../../fixtures/pt.json'))
addMessages('pt-BR', require('../../fixtures/pt-BR.json'))
addMessages('pt-PT', require('../../fixtures/pt-PT.json'))
beforeEach(() => {
init({ fallbackLocale: 'en' })
})
test('formats a message by its id and the current locale', () => {
expect(format({ id: 'form.field_1_name' })).toBe('Name')
})
test('formats a message by its id and the a passed locale', () => {
expect(format({ id: 'form.field_1_name', locale: 'pt' })).toBe('Nome')
})
test('formats a message with interpolated values', () => {
expect(format({ id: 'photos', values: { n: 0 } })).toBe('You have no photos.')
expect(format({ id: 'photos', values: { n: 1 } })).toBe('You have one photo.')
expect(format({ id: 'photos', values: { n: 21 } })).toBe(
'You have 21 photos.'
)
})
test('accepts a message id as first argument', () => {
expect(format('form.field_1_name')).toBe('Name')
})
test('accepts a message id as first argument and formatting options as second', () => {
expect(format('form.field_1_name', { locale: 'pt' })).toBe('Nome')
})
test('throws if no locale is set', () => {
$locale.set(null)
expect(() => format('form.field_1_name')).toThrow(
'[svelte-i18n] Cannot format a message without first setting the initial locale.'
)
})
test('uses a missing message default value', () => {
expect(format('missing', { default: 'Missing Default' })).toBe(
'Missing Default'
)
})
test('warn on missing messages', () => {
const warn = global.console.warn
global.console.warn = jest.fn()
format('missing')
expect(console.warn).toBeCalledWith(
`[svelte-i18n] The message "missing" was not found in "en".`
)
global.console.warn = warn
})
describe('format utilities', () => {
test('time', () => {
expect(format.time(new Date(2019, 0, 1, 20, 37))).toBe('8:37 PM')
})
test('date', () => {
expect(format.date(new Date(2019, 0, 1, 20, 37))).toBe('1/1/19')
})
test('number', () => {
expect(format.number(123123123)).toBe('123,123,123')
})
test('capital', () => {
expect(format.capital('title')).toBe('Page title')
})
test('title', () => {
expect(format.title('title')).toBe('Page Title')
})
test('upper', () => {
expect(format.upper('title')).toBe('PAGE TITLE')
})
test('lower', () => {
expect(format.lower('title')).toBe('page title')
})
})

View File

@ -0,0 +1,136 @@
import { get } from 'svelte/store'
import { lookupMessage } from '../../../src/client/includes/lookup'
import {
isFallbackLocaleOf,
getFallbackOf,
getRelatedLocalesOf,
getCurrentLocale,
$locale,
isRelatedLocale,
} from '../../../src/client/stores/locale'
import { getOptions, init } from '../../../src/client/configs'
import { register } from '../../../src/client'
import { hasLocaleQueue } from '../../../src/client/includes/loaderQueue'
beforeEach(() => {
init({ fallbackLocale: undefined })
$locale.set(undefined)
})
test('sets and gets the fallback locale', () => {
init({ fallbackLocale: 'en' })
expect(getOptions().fallbackLocale).toBe('en')
})
test('checks if a locale is a fallback locale of another locale', () => {
expect(isFallbackLocaleOf('en', 'en-US')).toBe(true)
expect(isFallbackLocaleOf('en', 'en')).toBe(false)
expect(isFallbackLocaleOf('it', 'en-US')).toBe(false)
})
test('checks if a locale is a fallback locale of another locale', () => {
expect(isRelatedLocale('en', 'en-US')).toBe(true)
expect(isRelatedLocale('pt-BR', 'pt')).toBe(true)
expect(isRelatedLocale('en', 'en')).toBe(true)
expect(isRelatedLocale('en', 'it-IT')).toBe(false)
expect(isRelatedLocale('en-US', 'it')).toBe(false)
})
test('gets the next fallback locale of a locale', () => {
expect(getFallbackOf('az-Cyrl-AZ')).toBe('az-Cyrl')
expect(getFallbackOf('en-US')).toBe('en')
expect(getFallbackOf('en')).toBe(null)
})
test('gets the global fallback locale if set', () => {
init({ fallbackLocale: 'en' })
expect(getFallbackOf('it')).toBe('en')
})
test('should not get the global fallback as the fallback of itself', () => {
init({ fallbackLocale: 'en' })
expect(getFallbackOf('en')).toBe(null)
})
test('if global fallback locale has a fallback, it should return it', () => {
init({ fallbackLocale: 'en-US' })
expect(getFallbackOf('en-US')).toBe('en')
})
test('gets all fallback locales of a locale', () => {
expect(getRelatedLocalesOf('en-US')).toEqual(['en', 'en-US'])
expect(getRelatedLocalesOf('en-US')).toEqual(['en', 'en-US'])
expect(getRelatedLocalesOf('az-Cyrl-AZ')).toEqual([
'az',
'az-Cyrl',
'az-Cyrl-AZ',
])
})
test('gets all fallback locales of a locale including the global fallback locale', () => {
init({ fallbackLocale: 'pt' })
expect(getRelatedLocalesOf('en-US')).toEqual(['en', 'en-US', 'pt'])
expect(getRelatedLocalesOf('en-US')).toEqual(['en', 'en-US', 'pt'])
expect(getRelatedLocalesOf('az-Cyrl-AZ')).toEqual([
'az',
'az-Cyrl',
'az-Cyrl-AZ',
'pt',
])
})
test('gets all fallback locales of a locale including the global fallback locale and its fallbacks', () => {
init({ fallbackLocale: 'pt-BR' })
expect(getRelatedLocalesOf('en-US')).toEqual(['en', 'en-US', 'pt', 'pt-BR'])
expect(getRelatedLocalesOf('en-US')).toEqual(['en', 'en-US', 'pt', 'pt-BR'])
expect(getRelatedLocalesOf('az-Cyrl-AZ')).toEqual([
'az',
'az-Cyrl',
'az-Cyrl-AZ',
'pt',
'pt-BR',
])
})
test("don't list fallback locale twice", () => {
init({ fallbackLocale: 'pt-BR' })
expect(getRelatedLocalesOf('pt-BR')).toEqual(['pt', 'pt-BR'])
expect(getRelatedLocalesOf('pt')).toEqual(['pt'])
})
test('gets the current locale', () => {
expect(getCurrentLocale()).toBe(undefined)
$locale.set('es-ES')
expect(getCurrentLocale()).toBe('es-ES')
})
test('if no initial locale is set, set the locale to the fallback', () => {
init({ fallbackLocale: 'pt' })
expect(get($locale)).toBe('pt')
expect(getOptions().fallbackLocale).toBe('pt')
})
test('if no initial locale was found, set to the fallback locale', () => {
init({
fallbackLocale: 'en',
initialLocale: {
hash: 'lang',
},
})
expect(get($locale)).toBe('en')
expect(getOptions().fallbackLocale).toBe('en')
})
test('should flush the queue of the locale when changing the store value', async () => {
register(
'en',
() => new Promise(res => setTimeout(() => res({ foo: 'Foo' }), 50))
)
expect(hasLocaleQueue('en')).toBe(true)
await $locale.set('en')
expect(hasLocaleQueue('en')).toBe(false)
expect(lookupMessage('foo', 'en')).toBe('Foo')
})

3
test/fixtures/en-GB.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"sneakers": "trainers"
}

9
test/fixtures/en.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"form": {
"field_1_name": "Name",
"field_2_name": "Lastname"
},
"photos": "You have {n, plural, =0 {no photos.} =1 {one photo.} other {# photos.}}",
"title": "Page title",
"sneakers": "sneakers"
}

8
test/fixtures/es.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"form": {
"field_1_name": "Nombre",
"field_2_name": "Apellido"
},
"title": "Título de la página",
"sneakers": "zapatillas"
}

16
test/fixtures/formats.json vendored Normal file
View File

@ -0,0 +1,16 @@
{
"number": {
"usd": { "style": "currency", "currency": "USD" },
"brl": { "style": "currency", "currency": "BRL" }
},
"date": {
"customDate": { "year": "numeric", "era": "short" }
},
"time": {
"customTime": {
"hour": "2-digit",
"minute": "2-digit",
"second": "2-digit"
}
}
}

5
test/fixtures/partials/en.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"page": {
"title_about": "About"
}
}

3
test/fixtures/pt-BR.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"french_bread": "pão francês"
}

3
test/fixtures/pt-PT.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"french_bread": "cacetinhos"
}

9
test/fixtures/pt.json vendored Normal file
View File

@ -0,0 +1,9 @@
{
"form": {
"field_1_name": "Nome",
"field_2_name": "Sobrenome"
},
"photos": "Você {n, plural, =0 {não tem fotos.} =1 {tem uma foto.} other {tem # fotos.}}",
"title": "Título da página",
"sneakers": "tênis"
}

5
test/fixtures/svelte.config.js vendored Normal file
View File

@ -0,0 +1,5 @@
const preprocess = require('svelte-preprocess')
module.exports = {
preprocess: preprocess(),
}

View File

@ -1,206 +0,0 @@
import {
dictionary,
locale,
format,
getClientLocale,
addCustomFormats,
customFormats,
} from '../src/index.js'
let _
let currentLocale
const dict = {
pt: {
hi: 'olá você',
'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: {
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}}',
},
}
format.subscribe(formatFn => {
_ = formatFn
})
dictionary.set(dict)
locale.subscribe(l => (currentLocale = l))
locale.set('pt')
it('should change locale', () => {
locale.set('pt')
expect(currentLocale).toBe('pt')
locale.set('en')
expect(currentLocale).toBe('en')
})
it('should fallback to existing locale', () => {
locale.set('pt-BR')
expect(currentLocale).toBe('pt')
locale.set('en-US')
expect(currentLocale).toBe('en')
})
it("should throw an error if locale doesn't exist", () => {
expect(() => locale.set('FOO')).toThrow()
})
it('should fallback to message id if id is not found', () => {
locale.set('en')
expect(_('batatinha')).toBe('batatinha')
})
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', { locale: 'pt' })).toBe('Trocar idioma')
expect(_('switch.lang', { locale: 'en' })).toBe('Switch language')
})
it('should interpolate message with variables', () => {
expect(_('greeting.message', { values: { name: 'Chris' } })).toBe(
'Hello Chris, how are you?',
)
})
it('should interpolate message with variables according to passed locale', () => {
expect(
_('greeting.message', { values: { name: 'Chris' }, locale: 'pt' }),
).toBe('Olá Chris, como vai?')
})
describe('utilities', () => {
describe('get locale', () => {
beforeEach(() => {
delete window.location
window.location = {
hash: '',
search: '',
}
})
it('should get the locale based on the passed hash parameter', () => {
window.location.hash = '#locale=en-US&lang=pt-BR'
expect(getClientLocale({ hash: 'locale' })).toBe('en-US')
expect(getClientLocale({ hash: 'lang' })).toBe('pt-BR')
})
it('should get the locale based on the passed search parameter', () => {
window.location.search = '?locale=en-US&lang=pt-BR'
expect(getClientLocale({ search: 'locale' })).toBe('en-US')
expect(getClientLocale({ search: 'lang' })).toBe('pt-BR')
})
it('should get the locale based on the navigator language', () => {
expect(getClientLocale({ navigator: true })).toBe(
window.navigator.language,
)
})
it('should get the fallback locale', () => {
expect(getClientLocale({ navigator: false, fallback: 'pt' })).toBe('pt')
expect(getClientLocale({ hash: 'locale', fallback: 'pt' })).toBe('pt')
})
})
describe('format utils', () => {
beforeAll(() => {
locale.set('en')
})
it('should capital a translated message', () => {
expect(_.capital('hi')).toBe('Hi yo')
})
it('should title a translated message', () => {
expect(_.title('hi')).toBe('Hi Yo')
})
it('should lowercase a translated message', () => {
expect(_.lower('hi')).toBe('hi yo')
})
it('should uppercase a translated message', () => {
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, { format: 'medium' })).toBe('11:45:00 PM')
})
it('should format a date value', () => {
expect(_.date(date)).toBe('4/24/19')
expect(_.date(date, { format: 'medium' })).toBe('Apr 24, 2019')
})
// number
it('should format a date value', () => {
expect(_.number(123123123)).toBe('123,123,123')
})
})
})
describe('custom formats', () => {
beforeAll(() => {
locale.set('pt-BR')
})
it('should have default number custom formats', () => {
expect(customFormats.number).toMatchObject({
scientific: { notation: 'scientific' },
engineering: { notation: 'engineering' },
compactLong: { notation: 'compact', compactDisplay: 'long' },
compactShort: { notation: 'compact', compactDisplay: 'short' },
})
})
it('should allow to add custom formats', () => {
addCustomFormats({
number: {
usd: { style: 'currency', currency: 'USD' },
},
})
expect(customFormats.number).toMatchObject({
usd: { style: 'currency', currency: 'USD' },
})
})
it('should format messages with custom formats', () => {
addCustomFormats({
number: {
usd: { style: 'currency', currency: 'USD' },
brl: { style: 'currency', currency: 'BRL' },
},
})
expect(_.number(123123123, { format: 'usd' })).toContain('US$')
expect(_.number(123123123, { format: 'usd' })).toContain('123.123.123,00')
expect(_.number(123123123, { format: 'brl' })).toContain('R$')
expect(_.number(123123123, { format: 'brl' })).toContain('123.123.123,00')
})
})

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"allowJs": true,
"noImplicitAny": true,
"sourceMap": false,
"module": "esnext",
"moduleResolution": "node",
"esModuleInterop": true,
"resolveJsonModule": true,
"target": "esnext",
"lib": ["es2018", "dom", "esnext"],
"outDir": "dist",
"types": ["svelte", "jest"]
},
"exclude": ["node_modules/**/*", "dist"]
}