refactor: 💡 rewrite to typescript

This commit is contained in:
Christian Kaisermann 2019-11-19 13:18:42 -03:00
parent d483244a9f
commit db21bb878a
66 changed files with 12629 additions and 10293 deletions

View File

@ -1 +1,2 @@
/test/fixtures
dist

View File

@ -1,5 +1,5 @@
{
"extends": ["kaisermann"],
"extends": ["kaisermann/typescript"],
"env": {
"browser": true,
"jest": true

225
README.md
View File

@ -2,11 +2,26 @@
> Internationalization for Svelte.
[See Demo](https://svelte-i18n.netlify.com/)
<!-- [See Demo](https://svelte-i18n.netlify.com/) -->
<!-- @import "[TOC]" {cmd="toc" depthFrom=2 depthTo=3 orderedList=false} -->
<!-- code_chunk_output -->
- [Usage](#usage)
- [Locale](#locale)
- [The dictionary](#the-dictionary)
- [Formatting](#formatting)
- [Formatting methods](#formatting-methods)
- [Formats](#formats)
- [CLI](#cli)
- [Options](#options)
<!-- /code_chunk_output -->
## Usage
`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.
`svelte-i18n` uses `stores` to keep 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.
---
@ -21,9 +36,7 @@ import { locale, dictionary, getClientLocale } from 'svelte-i18n'
locale.set('en-US')
// This is a store, so we can subscribe to its changes
locale.subscribe(() => {
console.log('locale change')
})
locale.subscribe(() => console.log('locale change'))
// svelte-i18n exports a method to help getting the current client locale
locale.set(
@ -95,7 +108,7 @@ Each language message dictionary can be as deep as you want. Messages can also b
### Formatting
The `_`/`format` store is the actual formatter method. To use it, it's simple as any other svelte store.
The `_`/`format` store is the actual formatter method. To use it it's simple as any other svelte store.
```html
<script>
@ -110,13 +123,13 @@ The `_`/`format` store is the actual formatter method. To use it, it's simple as
```html
<div>
{$_('greeting.message', { name: 'John' })}
{$_('greeting.message', { values: { name: 'John' }})}
<!-- Hello John, how are you? -->
{$_('photos', { n: 0 })}
{$_('photos', { values: { n: 0 }})}
<!-- You have no photos. -->
{$_('photos', { n: 12 })}
{$_('photos', { values: { n: 12} })}
<!-- You have 12 photos. -->
</div>
```
@ -125,11 +138,28 @@ The `_`/`format` store is the actual formatter method. To use it, it's simple as
#### `_` / `format`
`function(messageId: string, locale:? string): string`
Main formatting method that formats a localized message by its `id`.
`function(messageId: string, interpolations?: object, locale:? string): string`
```ts
function(messageId: string, options?: MessageObject): string
function(options: MessageObject): string
Main formatting method that formats a localized message by its id.
interface MessageObject {
id?: string
locale?: string
format?: string
default?: string
values?: Record<string, string | number | Date>
}
```
- `id`: represents the path to a specific message;
- `locale`: forces a specific locale;
- `default`: the default value in case of message not found in the current locale;
- `format`: the format to be used. See [#formats](#formats);
- `values`: properties that should be interpolated in the message;
You can pass a `string` as the first parameter for a less verbose way of formatting a message.
```html
<script>
@ -138,110 +168,155 @@ Main formatting method that formats a localized message by its id.
<div>{$_('greeting.ask')}</div>
<!-- Please type your name -->
```
#### `_.upper`
Transforms a localized message into uppercase.
```html
<script>
import { _ } from 'svelte-i18n'
</script>
<div>{$_.upper('greeting.ask')}</div>
<!-- PLEASE TYPE YOUR NAME -->
```
#### `_.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>
<div>{$_({ id: 'greeting.ask' })}</div>
<!-- Please type your name -->
```
#### `_.title`
The formatter method also provides some casing utilities:
Transform the message into title case.
- `_.upper` - transforms a localized message into uppercase;
- `_.lower` - transforms a localized message into lowercase;
- `_.capital` - capitalize a localized message;
- `_.title` - transforms the message into title case;
```html
<script>
import { _ } from 'svelte-i18n'
</script>
<div>{$_.upper('greeting.ask')}</div>
<!-- PLEASE TYPE YOUR NAME -->
<div>{$_.lower('greeting.ask')}</div>
<!-- please type your name -->
<div>{$_.capital('greeting.ask')}</div>
<!-- Please type your name -->
<div>{$_.title('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 [#formats](#formats) section to see available formats.
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
```ts
function(time: Date, options: MessageObject): string
```
```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>
<div>{$_.time(new Date(2019, 3, 24, 23, 45), { format: '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 [#formats](#formats) section to see available formats.
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
```ts
function(date: Date, options: MessageObject): string
```
```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>
<div>{$_.date(new Date(2019, 3, 24, 23, 45), { format: 'medium' } )}</div>
<!-- Apr 24, 2019 -->
```
#### `_.number`
`function(number: Number, locale?: string)`
Formats a number with the specified locale and format. Please refer to the [#formats](#formats) section to see available formats.
Formats a number with the specified locale
```ts
function(number: number, options: MessageObject): string
```
```html
<script>
import { _ } from 'svelte-i18n'
</script>
<div>{$_.number(100000000)}</div>
<!-- 100,000,000 -->
<div>{$_.number(100000000, 'pt')}</div>
<div>{$_.number(100000000, { locale: 'pt' })}</div>
<!-- 100.000.000 -->
```
### Formats
`svelte-i18n` comes with a set of default `number`, `time` and `date` formats:
**Number:**
- `currency`: `{ style: 'currency' }`
- `percent`: `{ style: 'percent' }`
- `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' }`
It's possible to define custom format styles via the `addCustomFormats` method if you need to supply a set of options to the underlying `Intl` formatter.
```ts
function addCustomFormats(formatsObject: Formats): void
interface Formats {
number: Record<string, Intl.NumberFormatOptions>
date: Record<string, Intl.DateTimeFormatOptions>
time: Record<string, Intl.DateTimeFormatOptions>
}
```
Please refer to the [Intl.NumberFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NumberFormat) and [Intl.DateTimeFormat](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat) documentations to see available formatting options.
**Example**:
```js
import { addCustomFormats } from 'svelte-i18n'
addCustomFormats({
number: {
EUR: {
style: 'currency',
currency: 'EUR',
},
},
})
```
```html
<div>
{$_.number(123456.789, { format: 'EUR' })}
</div>
<!-- 123.456,79 € -->
```
## CLI
`svelte-i18n` provides a command-line interface to extract all your messages to the `stdout` or to a specific JSON file.
```bash
$ svelte-i18n extract [options] <glob-pattern> [output-file]
```
### Options
- `-s, --shallow` - extract all messages to a shallow object, without creating nested objects. Default: `false`.
- `--overwrite` - overwrite the content of the `output` file instead of just appending missing properties. Default: `false`.
- `-c, --configDir` - define the directory of a [`svelte.config.js`](https://github.com/UnwrittenFun/svelte-vscode#generic-setup) in case your svelte components need to be preprocessed.

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).

4
example/cypress.json Normal file
View File

@ -0,0 +1,4 @@
{
"baseUrl": "http://localhost:3000",
"video": false
}

View File

@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@ -0,0 +1,19 @@
describe('Sapper template app', () => {
beforeEach(() => {
cy.visit('/')
});
it('has the correct <h1>', () => {
cy.contains('h1', 'Great success!')
});
it('navigates to /about', () => {
cy.get('nav a').contains('about').click();
cy.url().should('include', '/about');
});
it('navigates to /blog', () => {
cy.get('nav a').contains('blog').click();
cy.url().should('include', '/blog');
});
});

View File

@ -0,0 +1,17 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
}

View File

@ -0,0 +1,25 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add("login", (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This is will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@ -0,0 +1,20 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

View File

@ -0,0 +1,13 @@
{
"title": {
"about": "About",
"index": "Sapper project template!"
},
"about_this_site": "About this site",
"about_content": ["This is the 'about' page. There's not much here."],
"messages": {
"success": "Great success!",
"high_five": "High five",
"try_editing": "Try editing this file (src/routes/index.svelte) to test live reloading."
}
}

View File

@ -0,0 +1,13 @@
{
"title": {
"about": "About",
"index": "Sapper project template!"
},
"about_this_site": "About this site",
"about_content": ["This is the 'about' page. There's not much here."],
"messages": {
"success": "Great success!",
"high_five": "High five",
"try_editing": "Try editing this file (src/routes/index.svelte) to test live reloading."
}
}

View File

@ -0,0 +1,13 @@
{
"title": {
"about": "Acerca de",
"index": " Plantilla de proyecto Sapper!"
},
"about_this_site": " Acerca de este sitio",
"about_content": ["Esta es la página 'acerca de'. No hay mucho aquí."],
"messages": {
"success": "Gran éxito!",
"high_five": "Cinco altos",
"try_editing": " Intente editar este archivo (src/routes/index.svelte) para probar la recarga en vivo."
}
}

View File

@ -0,0 +1,13 @@
{
"title": {
"about": "Sobre",
"index": "Modelo de projeto em Sapper!"
},
"about_this_site": "Sobre este site",
"about_content": ["Esta é a página 'sobre'. Não há muito aqui."],
"messages": {
"success": "Suuuucesso!",
"high_five": "Toca aqui",
"try_editing": "Tente editar este arquivo (src/routes/index.svelte) para testar o recarregamento ao vivo."
}
}

2716
example/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,38 @@
{
"name": "svelte-app",
"version": "1.0.0",
"name": "TODO",
"description": "TODO",
"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": "sapper export --legacy",
"start": "node __sapper__/build",
"cy:run": "cypress run",
"cy:open": "cypress open",
"test": "run-p --race dev cy:run"
},
"dependencies": {
"svelte-i18n": "^1.1.0"
"compression": "^1.7.1",
"polka": "next",
"rollup-plugin-json": "^4.0.0",
"sirv": "^0.4.0",
"svelte-i18n": "^2.0.0-alpha.2"
},
"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-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,118 @@
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({
// 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')
dev,
hydratable: true,
emitCss: true,
}),
resolve({
browser: true,
dedupe,
}),
commonjs(),
json(),
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,
},
],
],
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —
// consult the documentation for details:
// https://github.com/rollup/rollup-plugin-commonjs
resolve(),
commonjs(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload('public'),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser(),
!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(),
],
external: Object.keys(pkg.dependencies).concat(
require('module').builtinModules ||
Object.keys(process.binding('natives')),
),
onwarn,
},
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(),
],
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,60 @@
<script>
export let segment;
</script>
<style>
nav {
border-bottom: 1px solid rgba(255,62,0,0.1);
font-weight: 300;
padding: 0 1em;
}
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 {
text-decoration: none;
padding: 1em 0.5em;
display: block;
}
</style>
<nav>
<ul>
<li><a class:selected='{segment === undefined}' href='.'>home</a></li>
<li><a class:selected='{segment === "about"}' href='about'>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'>blog</a></li>
</ul>
</nav>

View File

@ -1,51 +1,7 @@
import {
locale,
dictionary,
getClientLocale,
addCustomFormats,
} from '../../src/index.js'
import { dictionary } 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)
'pt-BR': () => import('../messages/pt-BR.json'),
'en-US': () => import('../messages/en-US.json'),
'es-ES': () => import('../messages/es-ES.json'),
})

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,41 @@
<script context="module">
import { locale, getClientLocale } from 'svelte-i18n'
export async function preload() {
return locale.set(getClientLocale({ default: 'en-US', navigator: true }))
}
</script>
<script>
import Nav from '../components/Nav.svelte'
const locales = {
'pt-BR': 'Português',
'en-US': 'English',
'es-ES': 'Espanõl',
}
export let segment
</script>
<style>
main {
position: relative;
max-width: 56em;
background-color: white;
padding: 2em;
margin: 0 auto;
box-sizing: border-box;
}
</style>
<Nav {segment} />
<main>
<select bind:value={$locale}>
{#each Object.entries(locales) as [locale, label]}
<option value={locale}>{label}</option>
{/each}
</select>
<slot />
</main>

View File

@ -0,0 +1,15 @@
<script>
import { _ } from 'svelte-i18n'
</script>
<svelte:head>
<title>{$_('title.about', { default: 'About' })}</title>
</svelte:head>
<h1>{$_('about_this_site', { default: 'About this site' })}</h1>
<p>
{$_('about_content[0]', {
default: "This is the 'about' page. There's not much here.",
})}
</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,82 @@
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"
}
]
}

1762
example/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

6964
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,11 @@
{
"name": "svelte-i18n",
"version": "1.1.2-beta",
"version": "2.0.0-alpha.2",
"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>",
@ -14,66 +17,67 @@
"localization",
"translation"
],
"engines": {
"node": ">= 11.15.0"
},
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"pretest": "npm run build",
"test": "jest --no-cache --verbose",
"test": "jest --no-cache",
"test:watch": "jest --no-cache --verbose --watchAll",
"lint": "eslint \"src/**/*.js\"",
"format": "prettier --loglevel silent --write \"src/**/*.js\" && eslint --fix \"src/**/*.js\"",
"lint": "eslint \"src/**/*.ts\"",
"format": "prettier --loglevel silent --write \"src/**/*.ts\" && eslint --fix \"src/**/*.ts\"",
"prepublishOnly": "npm run format && npm run test && npm run build"
},
"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
"^.+\\.tsx?$": "ts-jest"
}
},
"collectCoverage": true
"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",
"eslint": "^6.6.0",
"eslint-config-kaisermann": "0.0.3",
"intl": "^1.2.5",
"jest": "^24.9.0",
"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",
"estree-walker": "^0.9.0",
"intl-messageformat": "^7.5.2",
"micro-memoize": "^4.0.8",
"object-resolve-path": "^1.1.1"
"object-resolve-path": "^1.1.1",
"tiny-glob": "^0.2.6"
}
}

View File

@ -1,19 +1,48 @@
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
// const externals = new Set([
// ...Object.keys(pkg.dependencies),
// ...Object.keys(pkg.peerDependencies),
// 'fs',
// 'path',
// ])
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()],
},
]

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

@ -0,0 +1,220 @@
import {
Node,
ObjectExpression,
Property,
ImportDeclaration,
ImportSpecifier,
CallExpression,
Identifier,
} from 'estree'
import resolvePath from 'object-resolve-path'
import { walk } from 'estree-walker'
import { Ast } from 'svelte/types/compiler/interfaces'
import { parse } from 'svelte/compiler'
const LIB_NAME = 'svelte-i18n'
const DEFINE_MESSAGES_METHOD_NAME = 'defineMessages'
const FORMAT_METHOD_NAMES = new Set(['format', '_'])
interface Message {
node: Node
meta: {
id?: string
default?: string
[key: string]: any
}
}
const isNumberString = (n: string) => !Number.isNaN(parseInt(n))
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)
}
function isFormatCall(node: Node, imports: Set<string>) {
if (node.type !== 'CallExpression') return false
let identifier: Identifier
if (node.callee.type === 'MemberExpression') {
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[]
}
function getObjFromExpression(exprNode: Node | ObjectExpression) {
if (exprNode.type !== 'ObjectExpression') return null
return exprNode.properties.reduce<Message>(
(acc, prop: Property) => {
// we only want primitives
if (prop.value.type !== 'Literal') return acc
if (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: {} },
)
}
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 formatCallsWalker(node: Node) {
if (isFormatCall(node, imports)) {
calls.push(node as CallExpression)
this.skip()
}
}
walk(ast.instance as any, { enter: formatCallsWalker })
walk(ast.html as any, { enter: formatCallsWalker })
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),
)
}
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)
}
if (pathNode.type !== 'Literal' || typeof pathNode.value !== 'string') {
return null
}
if (options && options.type === 'ObjectExpression') {
const messageObj = getObjFromExpression(options)
messageObj.meta.id = pathNode.value
return messageObj
}
return {
node: pathNode,
meta: { id: pathNode.value },
}
}),
].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 resolvePath(accumulator, message.meta.id) !== 'undefined'
) {
return
}
deepSet(accumulator, message.meta.id, defaultValue)
}
})
return accumulator
}

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)

80
src/client/formatters.ts Normal file
View File

@ -0,0 +1,80 @@
import IntlMessageFormat, { Formats } from 'intl-messageformat'
import memoize from 'micro-memoize'
import { MemoizedIntlFormatter } from './types'
export const customFormats: any = {
number: {
scientific: { notation: 'scientific' },
engineering: { notation: 'engineering' },
compactLong: { notation: 'compact', compactDisplay: 'long' },
compactShort: { notation: 'compact', compactDisplay: 'short' },
},
date: {},
time: {},
}
export function addCustomFormats(formats: Partial<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)
}
const getIntlFormatterOptions = (
type: 'time' | 'number' | 'date',
name: string,
): any => {
if (type in customFormats && name in customFormats[type]) {
return customFormats[type][name]
}
if (
type in IntlMessageFormat.formats &&
name in IntlMessageFormat.formats[type]
) {
return (IntlMessageFormat.formats[type] as any)[name]
}
return null
}
export const getNumberFormatter: MemoizedIntlFormatter<
Intl.NumberFormat,
Intl.NumberFormatOptions
> = memoize((locale, options = {}) => {
if (options.locale) locale = options.locale
if (options.format) {
const format = getIntlFormatterOptions('number', options.format)
if (format) options = format
}
return new Intl.NumberFormat(locale, options)
})
export const getDateFormatter: MemoizedIntlFormatter<
Intl.DateTimeFormat,
Intl.DateTimeFormatOptions
> = memoize((locale, options = { format: 'short' }) => {
if (options.locale) locale = options.locale
if (options.format) {
const format = getIntlFormatterOptions('date', options.format)
if (format) options = format
}
return new Intl.DateTimeFormat(locale, options)
})
export const getTimeFormatter: MemoizedIntlFormatter<
Intl.DateTimeFormat,
Intl.DateTimeFormatOptions
> = memoize((locale, options = { format: 'short' }) => {
if (options.locale) locale = options.locale
if (options.format) {
const format = getIntlFormatterOptions('time', options.format)
if (format) options = format
}
return new Intl.DateTimeFormat(locale, options)
})
export const getMessageFormatter = memoize(
(message: string, locale: string) =>
new IntlMessageFormat(message, locale, customFormats),
)

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

@ -0,0 +1,118 @@
// todo maybe locale can be a promise so we can await for it on the template?
import { writable, derived } from 'svelte/store'
import resolvePath from 'object-resolve-path'
import memoize from 'micro-memoize'
import { capital, title, upper, lower, getClientLocale } from './utils'
import { MessageObject, Formatter } from './types'
import {
getMessageFormatter,
getDateFormatter,
getNumberFormatter,
getTimeFormatter,
} from './formatters'
let currentLocale: string
let currentDictionary: Record<string, any>
function getAvailableLocale(locale: string) {
if (currentDictionary[locale]) {
if (typeof currentDictionary[locale] === 'function') {
return { locale, loader: currentDictionary[locale] }
}
return { locale }
}
locale = locale.split('-').shift() //
if (currentDictionary[locale]) {
if (typeof currentDictionary[locale] === 'function') {
return { locale, loader: currentDictionary[locale] }
}
return { locale }
}
return { locale: null }
}
const lookupMessage = memoize((path: string, locale: string) => {
return (
(currentDictionary[locale] as any)[path] ||
resolvePath(currentDictionary[locale], path)
)
})
const formatMessage: Formatter = (id, options = {}) => {
if (typeof id === 'object') {
options = id as MessageObject
id = options.id
}
const { values, locale = currentLocale, default: defaultValue } = options
const message = lookupMessage(id, locale)
if (!message) {
console.warn(
`[svelte-i18n] The message "${id}" was not found in the locale "${locale}".`,
)
if (defaultValue != null) return defaultValue
return id
}
if (!values) return message
return getMessageFormatter(message, locale).format(values)
}
formatMessage.time = (t, options) =>
getTimeFormatter(currentLocale, options).format(t)
formatMessage.date = (d, options) =>
getDateFormatter(currentLocale, options).format(d)
formatMessage.number = (n, options) =>
getNumberFormatter(currentLocale, options).format(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: any) => (currentDictionary = newDictionary),
)
const locale = writable(null)
const localeSet = locale.set
locale.set = (newLocale: string) => {
const { locale, loader } = getAvailableLocale(newLocale)
if (typeof loader === 'function') {
return loader()
.then((dict: any) => {
currentDictionary[locale] = dict.default || dict
if (locale) return localeSet(locale)
})
.catch((e: Error) => {
throw e
})
}
if (locale) return localeSet(locale)
throw Error(`[svelte-i18n] Locale "${newLocale}" not found.`)
}
locale.update = (fn: (locale: string) => string) => localeSet(fn(currentLocale))
locale.subscribe((newLocale: string) => (currentLocale = newLocale))
const format = derived([locale, dictionary], () => formatMessage)
// defineMessages allow us to define and extract dynamic message ids
const defineMessages = (i: Record<string, MessageObject>) => i
export { customFormats, addCustomFormats } from './formatters'
export {
locale,
dictionary,
getClientLocale,
defineMessages,
format as _,
format,
}

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

@ -0,0 +1 @@
declare module 'object-resolve-path'

39
src/client/types.ts Normal file
View File

@ -0,0 +1,39 @@
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> {
(locale: string, 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
}

51
src/client/utils.ts Normal file
View File

@ -0,0 +1,51 @@
export const capital = (str: string) =>
str.replace(/(^|\s)\S/, l => l.toLocaleUpperCase())
export const title = (str: string) =>
str.replace(/(^|\s)\S/g, l => l.toLocaleUpperCase())
export const upper = (str: string) => str.toLocaleUpperCase()
export const lower = (str: string) => str.toLocaleLowerCase()
export const getClientLocale = ({
navigator,
hash,
search,
default: defaultLocale,
fallback = defaultLocale,
}: {
navigator?: boolean
hash?: string
search?: string
fallback?: string
default?: string
}) => {
let locale
const getFromURL = (urlPart: string, key: string) => {
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) {
locale = getFromURL(window.location.search, search)
}
if (hash && !locale) {
locale = getFromURL(window.location.hash, hash)
}
}
return locale || defaultLocale || fallback
}

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
}

227
test/cli/extraction.test.ts Normal file
View File

@ -0,0 +1,227 @@
// 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', () => {
it('should return nothing if there are no imports from the library', () => {
const ast = parse(`<script>const $_ = () => 0; $_();</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(0)
})
it('should collect 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' })
})
it('should collect all format calls with renamed imports', () => {
const ast = parse(`<script>
import { format as _x, _ as intl } from 'svelte-i18n'
$_x('foo')
$intl({ id: 'bar' })
</script>`)
const calls = collectFormatCalls(ast)
expect(calls).toHaveLength(2)
expect(calls[0]).toMatchObject({ type: 'CallExpression' })
expect(calls[1]).toMatchObject({ type: 'CallExpression' })
})
it('should collect all format utility calls', () => {
const ast = parse(`<script>
import { _ } from 'svelte-i18n'
$_.title('foo')
$_.capitalize({ id: 'bar' })
$_.number(10000)
</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' })
})
})
describe('collecting message definitions', () => {
it('should return 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)
})
it('should get 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', () => {
it('should collect 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', () => {
it('should return 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: '' })
})
it('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: ['', '', ''],
})
})
it('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]': '',
})
})
it('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: ['', '', ''],
})
})
it('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

@ -1,3 +1,4 @@
import { Formatter } from '../../src/client/types'
import {
dictionary,
locale,
@ -5,89 +6,101 @@ import {
getClientLocale,
addCustomFormats,
customFormats,
} from '../src/index.js'
} from '../../src/client'
let _
let currentLocale
global.Intl = require('intl')
let _: Formatter
let currentLocale: string
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}}',
},
pt: require('../fixtures/pt.json'),
en: require('../fixtures/en.json'),
}
format.subscribe(formatFn => {
_ = formatFn
})
dictionary.set(dict)
locale.subscribe(l => (currentLocale = l))
locale.subscribe((l: string) => {
currentLocale = l
})
locale.set('pt')
it('should change locale', () => {
describe('locale', () => {
it('should change locale', () => {
locale.set('pt')
expect(currentLocale).toBe('pt')
locale.set('en')
expect(currentLocale).toBe('en')
})
})
it('should fallback to existing locale', () => {
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", () => {
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', () => {
describe('dictionary', () => {
// todo test this better
it('allows to dynamically import a dictionary', async () => {
dictionary.update((dict: any) => {
dict.es = () => import('../fixtures/es.json')
return dict
})
await locale.set('es')
expect(currentLocale).toBe('es')
})
})
describe('formatting', () => {
it('should fallback to message id if id is not found', () => {
locale.set('en')
expect(_('batatinha')).toBe('batatinha')
})
expect(_('batatinha.quente')).toBe('batatinha.quente')
})
it('should translate to current locale', () => {
it('should fallback to default value if id is not found', () => {
locale.set('en')
expect(_('batatinha.quente', { default: 'Hot Potato' })).toBe('Hot Potato')
})
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')
it('should accept single object with id prop as the message path', () => {
locale.set('pt')
expect(_({ id: 'switch.lang' })).toBe('Trocar idioma')
locale.set('en')
expect(_({ id: 'switch.lang' })).toBe('Switch language')
})
it('should translate to passed locale', () => {
expect(_({ id: 'switch.lang', locale: 'pt' })).toBe('Trocar idioma')
expect(_('switch.lang', { locale: 'en' })).toBe('Switch language')
})
})
it('should interpolate message with variables', () => {
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', () => {
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', () => {
@ -97,7 +110,7 @@ describe('utilities', () => {
window.location = {
hash: '',
search: '',
}
} as any
})
it('should get the locale based on the passed hash parameter', () => {
@ -150,6 +163,9 @@ describe('utilities', () => {
locale.set('en')
expect(_.time(date)).toBe('11:45 PM')
expect(_.time(date, { format: 'medium' })).toBe('11:45:00 PM')
expect(_.time(date, { format: 'medium', locale: 'pt-BR' })).toBe(
'23:45:00',
)
})
it('should format a date value', () => {
@ -195,12 +211,25 @@ describe('custom formats', () => {
usd: { style: 'currency', currency: 'USD' },
brl: { style: 'currency', currency: 'BRL' },
},
date: {
customDate: { year: 'numeric', era: 'short' },
},
time: {
customTime: { hour: '2-digit', month: 'narrow' },
},
})
expect(_.number(123123123, { format: 'usd' })).toContain('US$')
expect(_.number(123123123, { format: 'usd' })).toContain('123,123,123.00')
locale.set('en-US')
expect(_.number(123123123, { format: 'brl' })).toContain('R$')
expect(_.number(123123123, { format: 'brl' })).toContain('123,123,123.00')
expect(_.number(123123123, { format: 'usd' })).toContain('$123,123,123.00')
expect(_.number(123123123, { format: 'brl' })).toContain('R$123,123,123.00')
expect(_.date(new Date(2019, 0, 1), { format: 'customDate' })).toEqual(
'2019 AD',
)
expect(
_.time(new Date(2019, 0, 1, 2, 0, 0), { format: 'customTime' }),
).toEqual('Jan, 02')
})
})

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

@ -0,0 +1,10 @@
{
"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}}"
}

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

@ -0,0 +1,10 @@
{
"hi": "hola yo",
"switch.lang": "Cambiar de idioma",
"greeting": {
"ask": "Por favor escriba su nombre",
"message": "Hola {name}, cómo estás?"
},
"photos": "Tienes {n, plural, =0 {0 fotos.} =1 {una foto.} other {# fotos.}}",
"cats": "Yo tengo {n, number} {n,plural,one{gato}other{gatos}}"
}

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

@ -0,0 +1,10 @@
{
"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}}"
}

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

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

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

5353
yarn.lock Normal file

File diff suppressed because it is too large Load Diff