├── .babelrc
├── .eslintrc.js
├── .gitignore
├── README.md
├── cli
├── emit-fluent-type-module.js
├── index.js
└── run-fluent-typescript.js
├── example-fluent-react
├── README.md
├── assets
│ └── locales
│ │ ├── jp
│ │ └── translations.ftl
│ │ ├── pt-br
│ │ └── translations.ftl
│ │ └── translations.ftl.d.ts
├── package-lock.json
├── package.json
├── src
│ ├── components
│ │ └── hello.tsx
│ ├── index.html
│ ├── index.tsx
│ └── l10n.tsx
├── tsconfig.json
├── types.d.ts
└── webpack.config.js
├── example-react-18next
├── README.md
├── assets
│ └── locales
│ │ ├── jp
│ │ └── translations.ftl
│ │ ├── pt-br
│ │ └── translations.ftl
│ │ └── translations.ftl.d.ts
├── package-lock.json
├── package.json
├── src
│ ├── components
│ │ └── hello.tsx
│ ├── i18n.ts
│ ├── index.html
│ └── index.tsx
├── tsconfig.json
├── types.d.ts
└── webpack.config.js
├── example-vanilla
├── README.md
├── assets
│ └── locales
│ │ ├── jp
│ │ └── translations.ftl
│ │ ├── pt-br
│ │ └── translations.ftl
│ │ └── translations.ftl.d.ts
├── package-lock.json
├── package.json
├── src
│ └── index.ts
├── tsconfig.json
├── types.d.ts
└── webpack.config.js
├── jest.config.js
├── package-lock.json
├── package.json
├── rollup.config.js
├── src
├── build
│ ├── header.ts
│ ├── index.ts
│ ├── type-messages-key.ts
│ └── type-pattern-arguments.ts
├── global-state
│ ├── index.ts
│ └── list-messages-variables.ts
├── helpers.ts
├── index.ts
└── types.d.ts
├── test
├── global-state.test.ts
└── index.test.ts
└── tsconfig.json
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "node": "current"
8 | }
9 | }
10 | ]
11 | ]
12 | }
13 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: '@typescript-eslint/parser',
3 | extends: [
4 | 'plugin:react/recommended',
5 | 'plugin:@typescript-eslint/recommended',
6 | ],
7 | parserOptions: {
8 | ecmaFeatures: {
9 | jsx: true,
10 | },
11 | },
12 | settings: {
13 | react: {
14 | version: 'detect',
15 | },
16 | },
17 | rules: {
18 | 'object-curly-spacing': ['error', 'always'],
19 | 'semi': ['error', 'never'],
20 | 'prefer-arrow-callback': 'error',
21 | 'quotes': ['error', 'single'],
22 | 'no-trailing-spaces': 'error',
23 | 'eol-last': ['error', 'always'],
24 | 'comma-dangle': [
25 | 'error',
26 | {
27 | arrays: 'always-multiline',
28 | objects: 'always-multiline',
29 | imports: 'always-multiline',
30 | exports: 'always-multiline',
31 | functions: 'never',
32 | },
33 | ],
34 | 'object-shorthand': ['error', 'always'],
35 | '@typescript-eslint/no-var-requires': 'off',
36 | '@typescript-eslint/explicit-function-return-type': 'off',
37 | '@typescript-eslint/indent': ['error', 2],
38 | '@typescript-eslint/member-delimiter-style': ['error', {
39 | singleline: {
40 | delimiter: 'comma',
41 | requireLast: false,
42 | },
43 | multiline: {
44 | delimiter: 'none',
45 | requireLast: true,
46 | },
47 | }],
48 | },
49 | }
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | example/dist
2 | node_modules
3 | dist/
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # fluent-typescript
2 | > 📦 Generate automatically TypeScript declarations for Fluent files
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | **Fluent** is a Mozilla's programming language for natural-sounding translations. And **fluent-typescript** is a tool to automatically generate type definitions for its mensagens. So you'll have safer changes on your translates messages by types safes provided by TS. Never more will have a missing variable or forget to delete an old message. Sounds like magic, right?
13 |
14 | Fluent client supported:
15 | - [x] vanilla (just [`@fluent/bundle`](https://www.npmjs.com/package/@fluent/bundle))
16 | - [x] [`react-18next`](https://www.npmjs.com/package/react-i18next)
17 | - [x] [`fluent-react`](https://github.com/projectfluent/fluent.js/tree/master/fluent-react)
18 |
19 | > :warning: It's a working in process project! At this moment, **you should not use it on production**!
20 |
21 | - [Official Fluent's website](https://projectfluent.org/)
22 | - [Fluent's Playground](https://projectfluent.org/play/)
23 |
24 | ## How to use
25 |
26 | As the first step, you need to install this package, of course:
27 |
28 | ```
29 | > npm install fluent-typescript --save-dev
30 | - or -
31 | > yarn add fluent-typescript --dev
32 | ```
33 |
34 | The following steps depends on which Fluent client that you are using:
35 |
36 | ### vanilla
37 |
38 | > You could check a complete example on [`/example-vanilla`](/example-vanilla) folder. Check its readme.
39 |
40 | Step by step:
41 |
42 | 1 - Add this script on your `package.json` config:
43 |
44 | ```js
45 | {
46 | "scripts": {
47 | "fluent-typescript": "./node_modules/.bin/fluent-typescript vanilla ./assets/locales/"
48 | }
49 | },
50 | ```
51 |
52 | The argument `./assets/locales/` is the path where the type definition file will be saved.
53 |
54 | 2 - Run `fluent-typescript`:
55 |
56 | ```
57 | > npm run fluent-typescript
58 | ```
59 |
60 | Now, your FTL files will be compiled into a `.d.ts`. The last remaining step is import it on our code.
61 |
62 | 3 - Add a cast on `FluentBundle` call:
63 |
64 | ```ts
65 | const bundle = new FluentBundle('pt-br') as FluentBundleTyped
66 | ```
67 |
68 | Finish! Now you have amazing types on your translations messages 🎉
69 |
70 | ### react-18next
71 |
72 | > You could check a complete example on [`/example-react-18next`](/example-react-18next) folder. Check its readme.
73 |
74 | 1 - You also need to install `@fluent/bundle`
75 |
76 | ```
77 | > npm install @fluent/bundle --save-dev
78 | - or -
79 | > yarn add @fluent/bundle --dev
80 | ```
81 |
82 | 2 - Add this script on your `package.json` config:
83 |
84 | ```js
85 | {
86 | "scripts": {
87 | "fluent-typescript": "./node_modules/.bin/fluent-typescript react-18next ./assets/locales/"
88 | }
89 | },
90 | ```
91 |
92 | The argument `./assets/locales/` is the path where the type definition file will be saved.
93 |
94 | 3 - Run `fluent-typescript`:
95 |
96 | ```
97 | > npm run fluent-typescript
98 | ```
99 |
100 | Now, your FTL files will be compiled into a `.d.ts`, and the type of `t` function returned by `useTranslation` will be patched, working out-of-the-box!
101 |
102 | > :warning: At this moment, it only works with `useTranslation`. Always when possible, prefer to use that instead of `Trans` or `withTranslation`, because you have type safe only with `t` function.
103 |
104 | Finish! Now you have amazing types on your translations messages 🎉
105 |
106 | ### fluent-react
107 |
108 | > You could check a complete example on [`/example-fluent-react`](/example-fluent-react) folder. Check its readme.
109 |
110 | 1 - Add this script on your `package.json` config:
111 |
112 | ```js
113 | {
114 | "scripts": {
115 | "fluent-typescript": "./node_modules/.bin/fluent-typescript fluent-react ./assets/locales/"
116 | }
117 | },
118 | ```
119 |
120 | The argument `./assets/locales/` is the path where the type definition file will be saved.
121 |
122 | 2 - Run `fluent-typescript`:
123 |
124 | ```
125 | > npm run fluent-typescript
126 | ```
127 |
128 | Now, your FTL files will be compiled into a `.d.ts`, and the type of `Localized` component from `@fluent/react` will be patched!
129 |
130 | To use the patched version of `Localized`, you should add the prop `typed`. For example:
131 |
132 | ```diff
133 | -
134 | +
135 | Hello!
136 |
137 | ```
138 |
139 | > :warning: At this moment, it only works with `Localized`. Always when possible, prefer to use that instead of `useLocalization`, because you have type safe only with `Localized` component.
140 |
141 | Finish! Now you have amazing types on your translations messages 🎉
142 |
143 | ## Flags
144 |
145 | ### --no-watch
146 |
147 | If you just want to emit the type definition, without running the watcher, you can use the flag `--no-watch`.
148 |
149 | For instance:
150 |
151 | ```
152 | > fluent-typescript vanilla ./assets/locales/ --no-watcha
153 | ```
154 |
155 | # How types are compiled
156 |
157 | ## Asymmetric translations
158 |
159 | **tl;dr:** You should always use all variables that a message could need.
160 |
161 | **Detailed explanation:**
162 |
163 | Let's say that we have a `hello` message on our application and we should translate that to Japanese and Portuguese. Since in Japanese is more common to use the last name, and in Portuguese is more natural to use the first name, we'll have that:
164 |
165 | ```ftl
166 | # locales/jp/translations.ftl
167 | hello = こんにちは{ $lastName }
168 |
169 | # locales/pt-br/translations.ftl
170 | hello = Olá { $firstName }
171 | ```
172 |
173 | Despite that _in practice_ we could just use `firstName` or `lastName`, our type definition file want to be most safe that could, so you'll always need to use all possibles arguments required by a message:
174 |
175 | ```ts
176 | bundle.formatPattern(helloMessage.value, { firstName: 'Macabeus', lastName: 'Aquino' }) // ok
177 | bundle.formatPattern(helloMessage.value, { firstName: 'Macabeus' }) // error
178 | bundle.formatPattern(helloMessage.value, { lastName: 'Aquino' }) // error
179 | ```
180 |
181 | Analogously on a message with selector. You should always use all variables:
182 |
183 | ```ftl
184 | award =
185 | { $place ->
186 | [first] You won first! Your prize is { $amount } bitcoins
187 | *[other] You won { $place }! Congratulations!
188 | }
189 | ```
190 |
191 | ```ts
192 | bundle.formatPattern(helloMessage.value, { place: 'first', amount: 0.1 }) // ok
193 | bundle.formatPattern(helloMessage.value, { place: 'second', amount: 0 }) // ok
194 | bundle.formatPattern(helloMessage.value, { place: 'first' }) // error
195 | bundle.formatPattern(helloMessage.value, { place: 'second' }) // error
196 | ```
197 |
198 | # Developing fluent-typescript
199 |
200 | When developing `fluent-typescript`, is important to build and watch, so you could check the changes automatically on the examples apps:
201 |
202 | ```
203 | > npm run start
204 | ```
205 |
206 | Check the readme on each example's folders to learn how to run them.
207 |
208 | You also could and run tests:
209 |
210 | ```
211 | > npm run test
212 | ```
213 |
--------------------------------------------------------------------------------
/cli/emit-fluent-type-module.js:
--------------------------------------------------------------------------------
1 | const { buildFluentTypeModule } = require('../dist')
2 |
3 | const emitFluentTypeModule = (fileSystemApi, typeDefinitionTarget, typeDefinitionFilepath) => {
4 | const fluentTypeModule = buildFluentTypeModule(typeDefinitionTarget)
5 | const typeDefinitionFilename = `${typeDefinitionFilepath}/translations.ftl.d.ts`
6 | fileSystemApi.writeFile(
7 | typeDefinitionFilename,
8 | fluentTypeModule,
9 | { encoding: 'utf-8' },
10 | (err) => {
11 | if (err) {
12 | console.log('❌ Error')
13 | console.log(err)
14 | return
15 | }
16 |
17 | console.log(`🏁 Type definition updated: ${typeDefinitionFilename}`)
18 | }
19 | )
20 | }
21 |
22 | module.exports = { emitFluentTypeModule }
23 |
--------------------------------------------------------------------------------
/cli/index.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const chokidar = require('chokidar')
4 | const fs = require('fs')
5 | const { updateContent, targetsSupported } = require('../dist')
6 | const { emitFluentTypeModule } = require('./emit-fluent-type-module')
7 | const { runFluentTypescript } = require('./run-fluent-typescript')
8 |
9 | const startWatcher = (fileSystemApi, typeDefinitionTarget, typeDefinitionFilepath) => {
10 | runFluentTypescript(fileSystemApi, typeDefinitionTarget, typeDefinitionFilepath)
11 |
12 | const watcher = chokidar.watch('**/*.ftl', { ignored: ['node_modules/**/*', '.git/**/*'] })
13 |
14 | watcher
15 | .on('ready', () => console.log('🎬 Ready!'))
16 | .on('unlink', (path) => {
17 | console.log(`🔍 File was deleted: ${path}`)
18 |
19 | const content = ''
20 | updateContent({ path, content })
21 |
22 | emitFluentTypeModule()
23 | })
24 | .on('change', (path) => {
25 | console.log(`🔍 File was changed: ${path}`)
26 |
27 | const content = fileSystemApi.readFileSync(path, { encoding: 'utf-8' })
28 | updateContent({ path, content })
29 |
30 | emitFluentTypeModule(fileSystemApi, typeDefinitionTarget, typeDefinitionFilepath)
31 | })
32 | }
33 |
34 | if (require.main === module) {
35 | const typeDefinitionTarget = process.argv[2]
36 | const typeDefinitionFilepath = process.argv[3]
37 | const noWatchFlag = process.argv[4]
38 |
39 | if (typeDefinitionTarget === undefined) {
40 | console.error('❌ Error: missing argument with the target!')
41 | console.error('Example: fluent-typescript vanilla ./assets/locales/')
42 | return
43 | }
44 |
45 | if (targetsSupported.includes(typeDefinitionTarget) === false) {
46 | console.error('❌ Error: target not supported!')
47 | console.error(`At this moment, we support only: ${targetsSupported.join(', ')}`)
48 | return
49 | }
50 |
51 | if (typeDefinitionFilepath === undefined) {
52 | console.error('❌ Error: missing argument with the path to save the type definition file!')
53 | console.error('Example: fluent-typescript vanilla ./assets/locales/')
54 | return
55 | }
56 |
57 | if (noWatchFlag === '--no-watch') {
58 | runFluentTypescript(fs, typeDefinitionTarget, typeDefinitionFilepath)
59 | return
60 | }
61 |
62 | if (noWatchFlag !== undefined) {
63 | console.error(`❌ Error: Unknown flag "${noWatchFlag}"`)
64 | console.error('Example: fluent-typescript vanilla ./assets/locales/ --no-watch')
65 | return
66 | }
67 |
68 | startWatcher(fs, typeDefinitionTarget, typeDefinitionFilepath)
69 | }
70 |
--------------------------------------------------------------------------------
/cli/run-fluent-typescript.js:
--------------------------------------------------------------------------------
1 | const glob = require('glob')
2 | const { normalize } = require('path')
3 | const { start } = require('../dist')
4 | const { emitFluentTypeModule } = require('./emit-fluent-type-module')
5 |
6 | const runFluentTypescript = (fileSystemApi, typeDefinitionTarget, typeDefinitionFilepath) => {
7 | glob('**/*.ftl', { ignore: ['node_modules/**/*', '.git/**/*'] }, (errors, matches) => {
8 | const files = matches.map(path => ({
9 | path: normalize(path),
10 | content: fileSystemApi.readFileSync(path, { encoding: 'utf-8' }),
11 | }))
12 |
13 | start(files)
14 | emitFluentTypeModule(fileSystemApi, typeDefinitionTarget, typeDefinitionFilepath)
15 | })
16 | }
17 |
18 | module.exports = { runFluentTypescript }
19 |
--------------------------------------------------------------------------------
/example-fluent-react/README.md:
--------------------------------------------------------------------------------
1 | ## How to run the example-fluent-react
2 |
3 | You can quicly run the example and see the magic happens.
4 |
5 | 1 - Download the dependencies:
6 |
7 | ```
8 | > npm i
9 | ```
10 |
11 | 2 - Start `fluent-typescript`:
12 |
13 | ```
14 | > npm run fluent-typescript
15 | ```
16 |
17 | 3 - Start Webpack:
18 |
19 | > Webpack isn't mandatory. It's just an example.
20 |
21 | ```
22 | > npm run start
23 | ```
24 |
25 | The application will be built and available on `http://localhost:8080/`. You can switch between Portuguese and Japanese using a selector on this page.
26 |
27 | But what happens if we adds a new variable on a message?
28 |
29 | 4 - Change `example-fluent-react/assets/locales/pt-br/translations.ftl` adding a new variable somewhere:
30 |
31 | ```diff
32 | -hello-no-name = Olá, estranho!
33 | +hello-no-name = { $time ->
34 | + [morning] Bom dia, estranho!
35 | + [afternoon] Bom tarde, estranho!
36 | + [night] Boa noite, estranho!
37 | + *[other] Olá, estranho!
38 | +}
39 | ```
40 |
41 | 5 - Now you'll see that your build process broke! Let's fix that on `example-fluent-react/src/components/index.ts`
42 |
43 | ```diff
44 | -
45 | +
46 | ```
47 |
48 | Now everything is fine again! Notice that we have a good auto-compelte, as well as something like that won't work:
49 |
50 | ```ts
51 | // not work because array is a wrong type
52 | // not work because of the wrong variable name
53 | ```
54 |
--------------------------------------------------------------------------------
/example-fluent-react/assets/locales/jp/translations.ftl:
--------------------------------------------------------------------------------
1 | hello-no-name = 奇妙なこんにちは!
2 | hello = こんにちは{ $lastName }
3 | type-name = お名前を書いてください
4 |
--------------------------------------------------------------------------------
/example-fluent-react/assets/locales/pt-br/translations.ftl:
--------------------------------------------------------------------------------
1 | hello-no-name = Olá, estranho!
2 | hello = Olá { $firstName }
3 | type-name = Escreva o seu nome
4 |
--------------------------------------------------------------------------------
/example-fluent-react/assets/locales/translations.ftl.d.ts:
--------------------------------------------------------------------------------
1 | // This file is automatically generated.
2 | // Please do not change this file!
3 |
4 | type Message0 = {
5 | id: T
6 | value: T
7 | attributes: Record
8 | }
9 |
10 | import { FluentVariable } from '@fluent/bundle'
11 | import { LocalizedProps } from '@fluent/react'
12 | import { ReactElement } from 'react'
13 |
14 | declare module '@fluent/react' {
15 | type LocalizedPropsWithoutIdAndVars = Omit, 'vars'>
16 |
17 | type LocalizedPropsPatched0 = (
18 | PatternArguments0[1] extends undefined
19 | ? {
20 | typed: true
21 | id: T
22 | } & LocalizedPropsWithoutIdAndVars
23 | : {
24 | typed: true
25 | id: T
26 | vars: PatternArguments0[1]
27 | } & LocalizedPropsWithoutIdAndVars
28 | )
29 |
30 | function Localized(props: LocalizedPropsPatched0): ReactElement;
31 | }
32 |
33 | type MessagesKey0 = 'hello-no-name' |
34 | 'hello' |
35 | 'type-name'
36 | type PatternArguments0 = (
37 | T extends 'hello-no-name'
38 | ? [T]:
39 | T extends 'hello'
40 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
41 | T extends 'type-name'
42 | ? [T]
43 | : never
44 | )
--------------------------------------------------------------------------------
/example-fluent-react/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fluent-typescript-example-fluent-react",
3 | "version": "0.0.1",
4 | "repository": {},
5 | "license": "MIT",
6 | "main": "src/index.tsx",
7 | "scripts": {
8 | "start": "webpack-dev-server --mode development --open --config webpack.config.js",
9 | "fluent-typescript": "./node_modules/.bin/fluent-typescript fluent-react ./assets/locales/"
10 | },
11 | "dependencies": {
12 | "react": "16.13.1",
13 | "react-dom": "16.13.1",
14 | "@fluent/bundle": "0.16.0",
15 | "@fluent/react": "0.13.0",
16 | "@fluent/langneg": "0.5.0"
17 | },
18 | "devDependencies": {
19 | "@types/react-dom": "16.9.7",
20 | "fluent-typescript": "file:../",
21 | "html-webpack-plugin": "4.3.0",
22 | "file-loader": "5.0.2",
23 | "ts-loader": "6.2.1",
24 | "typescript": "3.8.3",
25 | "webpack": "4.43.0",
26 | "webpack-cli": "3.3.11",
27 | "webpack-dev-server": "3.11.0"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/example-fluent-react/src/components/hello.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { Localized } from '@fluent/react'
3 |
4 | const Hello = () => {
5 | const [userName, setUserName] = useState('')
6 |
7 | const names = userName.split(' ')
8 | const firstName = names[0]
9 | const lastName = names[names.length - 1]
10 |
11 | return (
12 |
13 | {
14 | userName
15 | ?
16 |
17 | Hello!
18 |
19 | :
20 |
21 | Hello, stranger!
22 |
23 | }
24 |
25 |
26 | setUserName(evt.target.value)}
30 | value={userName}
31 | />
32 |
33 |
34 | )
35 | }
36 |
37 | export default Hello
38 |
--------------------------------------------------------------------------------
/example-fluent-react/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | fluent-typescript + fluent-react
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/example-fluent-react/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 |
4 | import AppLocalizationProvider from './l10n'
5 | import Hello from './components/hello'
6 |
7 | const App = () => (
8 |
9 |
10 |
11 | )
12 |
13 | ReactDOM.render( , document.getElementById('app'))
14 |
--------------------------------------------------------------------------------
/example-fluent-react/src/l10n.tsx:
--------------------------------------------------------------------------------
1 | import React, { Children, useEffect, useState, ReactNode } from 'react'
2 | import { negotiateLanguages } from '@fluent/langneg'
3 | import { FluentBundle, FluentResource } from '@fluent/bundle'
4 | import { ReactLocalization, LocalizationProvider } from '@fluent/react'
5 | import ftlTranslationsPathPt from '../assets/locales/pt-br/translations.ftl'
6 | import ftlTranslationsPathJp from '../assets/locales/jp/translations.ftl'
7 |
8 | const translations = {
9 | pt: ftlTranslationsPathPt,
10 | jp: ftlTranslationsPathJp,
11 | }
12 |
13 | type AvailableLocaleCodes = keyof typeof translations
14 |
15 | const availableLocales: { [key in AvailableLocaleCodes]: string } = {
16 | pt: 'Portuguese',
17 | jp: 'Japanese',
18 | }
19 |
20 | const defaultLanguage: AvailableLocaleCodes = 'pt'
21 |
22 | const fetchMessages = async (locale: AvailableLocaleCodes) => {
23 | const response = await fetch(translations[locale])
24 | const messages = await response.text()
25 | return [locale, messages] as [AvailableLocaleCodes, string]
26 | }
27 |
28 | function* lazilyParsedBundles(fetchedMessages: Array<[string, string]>) {
29 | for (const [locale, messages] of fetchedMessages) {
30 | const resource = new FluentResource(messages)
31 | const bundle = new FluentBundle(locale)
32 | bundle.addResource(resource)
33 | yield bundle
34 | }
35 | }
36 |
37 | type AppLocalizationProviderProps = {
38 | children: ReactNode
39 | }
40 |
41 | function AppLocalizationProvider(props: AppLocalizationProviderProps) {
42 | const [currentLocales, setCurrentLocales] = useState([defaultLanguage])
43 | const [l10n, setL10n] = useState(null)
44 |
45 | async function changeLocales(userLocales: Array) {
46 | const currentLocales = negotiateLanguages(
47 | userLocales,
48 | Object.keys(availableLocales),
49 | { defaultLocale: defaultLanguage }
50 | ) as AvailableLocaleCodes[]
51 | setCurrentLocales(currentLocales)
52 |
53 | const fetchedMessages = await Promise.all(
54 | currentLocales.map(fetchMessages)
55 | )
56 |
57 | const bundles = lazilyParsedBundles(fetchedMessages)
58 | setL10n(new ReactLocalization(bundles))
59 | }
60 |
61 | useEffect(() => {
62 | changeLocales(navigator.languages as Array)
63 | }, [])
64 |
65 | if (l10n === null) {
66 | return Loading…
67 | }
68 |
69 | return (
70 | <>
71 |
72 | {Children.only(props.children)}
73 |
74 |
75 |
76 |
77 | changeLocales([event.target.value])}
79 | value={currentLocales[0]}>
80 | {Object.entries(availableLocales).map(
81 | ([code, name]) => {name}
82 | )}
83 |
84 | >
85 | )
86 | }
87 |
88 | export default AppLocalizationProvider
89 |
--------------------------------------------------------------------------------
/example-fluent-react/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "allowSyntheticDefaultImports": true,
5 | "sourceMap": true,
6 | "noImplicitAny": true,
7 | "moduleResolution": "node",
8 | "module": "es6",
9 | "target": "es5",
10 | "strictNullChecks": true,
11 | "rootDirs": ["src"],
12 | "jsx": "react"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/example-fluent-react/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.ftl' {
2 | const content: string
3 | export default content
4 | }
5 |
--------------------------------------------------------------------------------
/example-fluent-react/webpack.config.js:
--------------------------------------------------------------------------------
1 | const HtmlWebPackPlugin = require('html-webpack-plugin')
2 |
3 | module.exports = () => ({
4 | module: {
5 | rules: [
6 | {
7 | test: /\.ftl$/,
8 | use: 'file-loader',
9 | },
10 | {
11 | test: /\.tsx?$/,
12 | use: 'ts-loader',
13 | exclude: /node_modules/,
14 | },
15 | ],
16 | },
17 | plugins: [
18 | new HtmlWebPackPlugin({
19 | filename: './index.html',
20 | template: './src/index.html',
21 | }),
22 | ],
23 | resolve: {
24 | extensions: ['.ts', '.tsx', '.js'],
25 | },
26 | })
27 |
--------------------------------------------------------------------------------
/example-react-18next/README.md:
--------------------------------------------------------------------------------
1 | ## How to run the example-react-18next
2 |
3 | You can quicly run the example and see the magic happens.
4 |
5 | 1 - Download the dependencies:
6 |
7 | ```
8 | > npm i
9 | ```
10 |
11 | 2 - Start `fluent-typescript`:
12 |
13 | ```
14 | > npm run fluent-typescript
15 | ```
16 |
17 | 3 - Start Webpack and Webpack Dev Server:
18 |
19 | > Webpack isn't mandatory to use `fluent-typescript` on your project. It's just an example.
20 |
21 | ```
22 | > npm run start
23 | ```
24 |
25 | The application will be built and available on `http://localhost:8080/`. You can switch between Portuguese and Japanese using the query string `lng`:
26 |
27 | ```
28 | http://localhost:8080/?lng=pt
29 | http://localhost:8080/?lng=jp
30 | ```
31 |
32 | But what happens if we adds a new variable on a message?
33 |
34 | 3 - Change `example-react-18next/assets/locales/pt-br/translations.ftl` adding a new variable somewhere:
35 |
36 | ```diff
37 | -bye = Tchau
38 | +bye = Tchau, { $name }
39 | ```
40 |
41 | 4 - Now you'll see that your build process broke! Let's fix that on `example-react-18next/src/components/index.tss`
42 |
43 | ```diff
44 | -{t('bye')}
45 | +{t('bye', { name: 'Macabeus' })}
46 | ```
47 |
48 | Now everything is fine again! Notice that we have a good auto-compelte, as well as something like that won't work:
49 |
50 | ```tsx
51 | {t('bye', { name: [] })} // not work because array is a wrong type
52 | {t('bye', { wrongVariable: 'Macabeus' })} // not work because of the wrong variable name
53 | ```
54 |
--------------------------------------------------------------------------------
/example-react-18next/assets/locales/jp/translations.ftl:
--------------------------------------------------------------------------------
1 | hello = こんにちは{ $lastName }
2 | how-are-you = お元気ですか?
3 | bye = じゃあね
4 |
--------------------------------------------------------------------------------
/example-react-18next/assets/locales/pt-br/translations.ftl:
--------------------------------------------------------------------------------
1 | hello = Olá { $firstName }
2 | how-are-you = Como você está?
3 | bye = Tchau
4 |
--------------------------------------------------------------------------------
/example-react-18next/assets/locales/translations.ftl.d.ts:
--------------------------------------------------------------------------------
1 | // This file is automatically generated.
2 | // Please do not change this file!
3 |
4 | type Message0 = {
5 | id: T
6 | value: T
7 | attributes: Record
8 | }
9 |
10 | import { FluentVariable } from '@fluent/bundle'
11 |
12 | declare module 'react-i18next' {
13 | interface UseTranslationResponsePatched extends Omit {
14 | t(...args: PatternArguments0): string
15 | }
16 |
17 | function useTranslation(ns?: Namespace, options?: UseTranslationOptions): UseTranslationResponsePatched
18 | }
19 |
20 | type MessagesKey0 = 'hello' |
21 | 'how-are-you' |
22 | 'bye'
23 | type PatternArguments0 = (
24 | T extends 'hello'
25 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
26 | T extends 'how-are-you'
27 | ? [T]:
28 | T extends 'bye'
29 | ? [T]
30 | : never
31 | )
--------------------------------------------------------------------------------
/example-react-18next/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fluent-typescript-example-react-18next",
3 | "version": "0.0.1",
4 | "repository": {},
5 | "license": "MIT",
6 | "main": "src/index.tsx",
7 | "scripts": {
8 | "start": "webpack-dev-server --mode development --open --config webpack.config.js",
9 | "fluent-typescript": "./node_modules/.bin/fluent-typescript react-18next ./assets/locales/"
10 | },
11 | "dependencies": {
12 | "react": "16.13.1",
13 | "react-dom": "16.13.1",
14 | "i18next": "19.4.4",
15 | "react-i18next": "11.4.0",
16 | "i18next-browser-languagedetector": "4.1.1",
17 | "i18next-fluent": "1.0.1",
18 | "i18next-fluent-backend": "0.0.2"
19 | },
20 | "devDependencies": {
21 | "@fluent/bundle": "0.16.0",
22 | "@types/react-dom": "16.9.7",
23 | "fluent-typescript": "file:../",
24 | "html-webpack-plugin": "4.3.0",
25 | "file-loader": "5.0.2",
26 | "ts-loader": "6.2.1",
27 | "typescript": "3.8.3",
28 | "webpack": "4.43.0",
29 | "webpack-cli": "3.3.11",
30 | "webpack-dev-server": "3.11.0"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/example-react-18next/src/components/hello.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTranslation } from 'react-i18next'
3 |
4 | const Hello = () => {
5 | const { t } = useTranslation()
6 |
7 | return (
8 | <>
9 | {t('hello', { firstName: 'Macabeus', lastName: 'Aquino' })}
10 | {t('bye')}
11 | >
12 | )
13 | }
14 |
15 | export default Hello
16 |
--------------------------------------------------------------------------------
/example-react-18next/src/i18n.ts:
--------------------------------------------------------------------------------
1 | import i18n, { InitOptions } from 'i18next'
2 | import { initReactI18next } from 'react-i18next'
3 | import LanguageDetector from 'i18next-browser-languagedetector'
4 | import Fluent from 'i18next-fluent'
5 | import FluentBackend from 'i18next-fluent-backend'
6 | import ftlTranslationsPathPt from '../assets/locales/pt-br/translations.ftl'
7 | import ftlTranslationsPathJp from '../assets/locales/jp/translations.ftl'
8 |
9 | const translations = {
10 | pt: ftlTranslationsPathPt,
11 | jp: ftlTranslationsPathJp,
12 | }
13 |
14 | const defaultLanguage = translations.pt
15 |
16 | const backend: InitOptions['backend'] = {
17 | loadPath: ([lng]: [string]) => (
18 | lng in translations
19 | ? translations[lng as keyof typeof translations]
20 | : defaultLanguage
21 | ),
22 | }
23 |
24 | i18n
25 | .use(LanguageDetector)
26 | .use(Fluent)
27 | .use(FluentBackend)
28 | .use(initReactI18next)
29 | .init({
30 | backend,
31 | keySeparator: false,
32 | interpolation: {
33 | escapeValue: false,
34 | },
35 | })
36 |
37 | export default i18n
38 |
--------------------------------------------------------------------------------
/example-react-18next/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | fluent-typescript + react-18next
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/example-react-18next/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense } from 'react'
2 | import ReactDOM from 'react-dom'
3 | import './i18n'
4 | import Hello from './components/hello'
5 |
6 | const App = () => (
7 | Loading...}>
8 |
9 |
10 |
11 |
12 | )
13 |
14 | ReactDOM.render( , document.getElementById('app'))
15 |
--------------------------------------------------------------------------------
/example-react-18next/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "allowSyntheticDefaultImports": true,
5 | "sourceMap": true,
6 | "noImplicitAny": true,
7 | "moduleResolution": "node",
8 | "module": "es6",
9 | "target": "es5",
10 | "strictNullChecks": true,
11 | "rootDirs": ["src"],
12 | "jsx": "react"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/example-react-18next/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'i18next-fluent'
2 | declare module 'i18next-fluent-backend'
3 |
4 | declare module '*.ftl' {
5 | const content: string
6 | export default content
7 | }
8 |
--------------------------------------------------------------------------------
/example-react-18next/webpack.config.js:
--------------------------------------------------------------------------------
1 | const HtmlWebPackPlugin = require('html-webpack-plugin')
2 |
3 | module.exports = () => ({
4 | module: {
5 | rules: [
6 | {
7 | test: /\.ftl$/,
8 | use: 'file-loader',
9 | },
10 | {
11 | test: /\.tsx?$/,
12 | use: 'ts-loader',
13 | exclude: /node_modules/,
14 | },
15 | ],
16 | },
17 | plugins: [
18 | new HtmlWebPackPlugin({
19 | filename: './index.html',
20 | template: './src/index.html',
21 | }),
22 | ],
23 | resolve: {
24 | extensions: ['.ts', '.tsx', '.js'],
25 | },
26 | })
27 |
--------------------------------------------------------------------------------
/example-vanilla/README.md:
--------------------------------------------------------------------------------
1 | ## How to run the example-vanilla
2 |
3 | You can quicly run the example and see the magic happens.
4 |
5 | 1 - Download the dependencies:
6 |
7 | ```
8 | > npm i
9 | ```
10 |
11 | 2 - Start `fluent-typescript`:
12 |
13 | ```
14 | > npm run fluent-typescript
15 | ```
16 |
17 | 3 - Start Webpack:
18 |
19 | > Webpack isn't mandatory. It's just an example.
20 |
21 | ```
22 | > npm run start
23 | ```
24 |
25 | The application will be built, we could run using `node dist/main.js`, but what happens if we adds a new variable on a message?
26 |
27 | 4 - Change `example-vanilla/assets/locales/pt-br/translations.ftl` adding a new variable somewhere:
28 |
29 | ```diff
30 | -bye = Tchau
31 | +bye = Tchau, { $name }
32 | ```
33 |
34 | 5 - Now you'll see that your build process broke! Let's fix that on `example-vanilla/src/index.ts`
35 |
36 | ```diff
37 | -const byeText = bundle.formatPattern(byeMessage.value)
38 | +const byeText = bundle.formatPattern(byeMessage.value, { name: 'Macabeus' })
39 | ```
40 |
41 | Now everything is fine again! Notice that we have a good auto-compelte, as well as something like that won't work:
42 |
43 | ```ts
44 | const byeText = bundle.formatPattern(byeMessage.value, { name: [] }) // not work because array is a wrong type
45 | const byeText = bundle.formatPattern(byeMessage.value, { wrongVariable: 'Macabeus' }) // not work because of the wrong variable name
46 | ```
47 |
--------------------------------------------------------------------------------
/example-vanilla/assets/locales/jp/translations.ftl:
--------------------------------------------------------------------------------
1 | hello = こんにちは{ $lastName }
2 | how-are-you = お元気ですか?
3 | bye = じゃあね
4 |
--------------------------------------------------------------------------------
/example-vanilla/assets/locales/pt-br/translations.ftl:
--------------------------------------------------------------------------------
1 | hello = Olá { $firstName }
2 | how-are-you = Como você está?
3 | bye = Tchau
4 |
--------------------------------------------------------------------------------
/example-vanilla/assets/locales/translations.ftl.d.ts:
--------------------------------------------------------------------------------
1 | // This file is automatically generated.
2 | // Please do not change this file!
3 |
4 | type Message0 = {
5 | id: T
6 | value: T
7 | attributes: Record
8 | }
9 |
10 | import { FluentBundle, FluentVariable } from '@fluent/bundle'
11 |
12 | declare global {
13 | interface FluentBundleTyped extends FluentBundle {
14 | getMessage(id: T): Message0
15 | formatPattern(...args: PatternArguments0): string
16 | }
17 | }
18 |
19 | type MessagesKey0 = 'hello' |
20 | 'how-are-you' |
21 | 'bye'
22 | type PatternArguments0 = (
23 | T extends 'hello'
24 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
25 | T extends 'how-are-you'
26 | ? [T]:
27 | T extends 'bye'
28 | ? [T]
29 | : never
30 | )
--------------------------------------------------------------------------------
/example-vanilla/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fluent-typescript-example-vanilla",
3 | "version": "0.0.1",
4 | "repository": {},
5 | "license": "MIT",
6 | "main": "src/index.ts",
7 | "scripts": {
8 | "start": "webpack --mode development --watch --config webpack.config.js",
9 | "fluent-typescript": "./node_modules/.bin/fluent-typescript vanilla ./assets/locales/"
10 | },
11 | "dependencies": {
12 | "@fluent/bundle": "0.16.0"
13 | },
14 | "devDependencies": {
15 | "fluent-typescript": "file:../",
16 | "ts-loader": "6.2.1",
17 | "typescript": "3.8.3",
18 | "raw-loader": "4.0.1",
19 | "webpack": "4.42.0",
20 | "webpack-cli": "3.3.11"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/example-vanilla/src/index.ts:
--------------------------------------------------------------------------------
1 | import { FluentBundle, FluentResource } from '@fluent/bundle'
2 | import ftl from '../assets/locales/pt-br/translations.ftl'
3 |
4 | const resource = new FluentResource(ftl as string)
5 | const bundle = new FluentBundle('pt-br') as FluentBundleTyped
6 | bundle.addResource(resource)
7 |
8 | const helloMessage = bundle.getMessage('hello')
9 | const helloText = bundle.formatPattern(helloMessage.value, { firstName: 'Macabeus', lastName: 'Aquino' })
10 | console.log('Hello:', helloText)
11 |
12 | const howAreYouMessage = bundle.getMessage('how-are-you')
13 | const howAreYouText = bundle.formatPattern(howAreYouMessage.value)
14 | console.log('How Are You:', howAreYouText)
15 |
16 | const byeMessage = bundle.getMessage('bye')
17 | const byeText = bundle.formatPattern(byeMessage.value)
18 | console.log('Bye:', byeText)
19 |
--------------------------------------------------------------------------------
/example-vanilla/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist/",
4 | "allowSyntheticDefaultImports": true,
5 | "sourceMap": true,
6 | "noImplicitAny": true,
7 | "moduleResolution": "node",
8 | "module": "es6",
9 | "target": "es5",
10 | "strictNullChecks": true,
11 | "rootDirs": ["src"]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/example-vanilla/types.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.ftl' {
2 | const content: string
3 | export default content
4 | }
5 |
--------------------------------------------------------------------------------
/example-vanilla/webpack.config.js:
--------------------------------------------------------------------------------
1 | module.exports = () => ({
2 | module: {
3 | rules: [
4 | {
5 | test: /\.ftl$/,
6 | use: 'raw-loader',
7 | },
8 | {
9 | test: /\.tsx?$/,
10 | use: 'ts-loader',
11 | exclude: /node_modules/,
12 | },
13 | ],
14 | },
15 | resolve: {
16 | extensions: ['.ts'],
17 | },
18 | })
19 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const tsPreset = require('ts-jest/jest-preset')
2 |
3 | module.exports = {
4 | testEnvironment: 'node',
5 | ...tsPreset,
6 | }
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "fluent-typescript",
3 | "version": "0.0.6",
4 | "description": "CLI tool to automatically generate TypeScript declarations for Fluent files",
5 | "main": "dist/index.js",
6 | "bin": {
7 | "fluent-typescript": "cli/index.js"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/macabeus/fluent-typescript-loader"
12 | },
13 | "author": "macabeus",
14 | "license": "MIT",
15 | "scripts": {
16 | "start": "./node_modules/.bin/rollup -c rollup.config.js --watch",
17 | "lint": "eslint './src/**/*.ts'",
18 | "test": "jest"
19 | },
20 | "dependencies": {
21 | "@fluent/syntax": "0.16.0",
22 | "chokidar": "3.4.0",
23 | "dedent-js": "1.0.1",
24 | "glob": "7.1.6",
25 | "loader-utils": "2.0.0"
26 | },
27 | "devDependencies": {
28 | "@babel/preset-env": "7.9.5",
29 | "@babel/plugin-transform-runtime": "7.9.0",
30 | "@rollup/plugin-typescript": "4.1.1",
31 | "@types/jest": "25.2.1",
32 | "@types/mocha": "7.0.2",
33 | "@typescript-eslint/eslint-plugin": "2.30.0",
34 | "@typescript-eslint/parser": "2.30.0",
35 | "babel-jest": "25.3.0",
36 | "eslint": "6.8.0",
37 | "eslint-plugin-react": "7.20.0",
38 | "jest": "25.3.0",
39 | "memfs": "3.1.2",
40 | "rollup": "2.7.2",
41 | "rollup-plugin-node-resolve": "5.2.0",
42 | "rollup-plugin-commonjs": "10.1.0",
43 | "ts-jest": "25.4.0",
44 | "tslib": "1.11.1",
45 | "typescript": "3.8.3"
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import typescript from '@rollup/plugin-typescript'
2 | import resolve from 'rollup-plugin-node-resolve'
3 | import commonJS from 'rollup-plugin-commonjs'
4 |
5 | export default {
6 | input: './src/index.ts',
7 | output: {
8 | file: './dist/index.js',
9 | format: 'iife',
10 | },
11 | plugins: [
12 | typescript({
13 | typescript: require('typescript'),
14 | exclude: ['node_modules', './example-react-18next', './example-vanilla'],
15 | }),
16 | resolve(),
17 | commonJS({
18 | include: 'node_modules/**',
19 | }),
20 | ],
21 | }
22 |
--------------------------------------------------------------------------------
/src/build/header.ts:
--------------------------------------------------------------------------------
1 | import dedent from 'dedent-js'
2 |
3 | const bannerMessage = (
4 | '// This file is automatically generated.\n// Please do not change this file!\n\n'
5 | )
6 |
7 | const vanillaSupport = (chunks: MessageVariablesChunks) => {
8 | const functions = chunks
9 | .map((_, index) => dedent`
10 | getMessage(id: T): Message${index}
11 | formatPattern(...args: PatternArguments${index}): string
12 | `)
13 | .join('\n ')
14 |
15 | return dedent`
16 | import { FluentBundle, FluentVariable } from '@fluent/bundle'
17 |
18 | declare global {
19 | interface FluentBundleTyped extends FluentBundle {
20 | ${functions}
21 | }
22 | }
23 | `
24 | }
25 |
26 | const i18NextSupport = (chunks: MessageVariablesChunks) => {
27 | const functions = chunks
28 | .map((_, index) =>
29 | `t(...args: PatternArguments${index}): string`
30 | )
31 | .join('\n ')
32 |
33 | return dedent`
34 | import { FluentVariable } from '@fluent/bundle'
35 |
36 | declare module 'react-i18next' {
37 | interface UseTranslationResponsePatched extends Omit {
38 | ${functions}
39 | }
40 |
41 | function useTranslation(ns?: Namespace, options?: UseTranslationOptions): UseTranslationResponsePatched
42 | }
43 | `
44 | }
45 |
46 | const fluentReactSupport = (chunks: MessageVariablesChunks) => {
47 | const functions = chunks
48 | .map((_, index) => dedent`
49 | type LocalizedPropsPatched${index} = (
50 | PatternArguments${index}[1] extends undefined
51 | ? {
52 | typed: true
53 | id: T
54 | } & LocalizedPropsWithoutIdAndVars
55 | : {
56 | typed: true
57 | id: T
58 | vars: PatternArguments${index}[1]
59 | } & LocalizedPropsWithoutIdAndVars
60 | )
61 |
62 | function Localized(props: LocalizedPropsPatched${index}): ReactElement;
63 | `)
64 | .join('\n ')
65 |
66 | return dedent`
67 | import { FluentVariable } from '@fluent/bundle'
68 | import { LocalizedProps } from '@fluent/react'
69 | import { ReactElement } from 'react'
70 |
71 | declare module '@fluent/react' {
72 | type LocalizedPropsWithoutIdAndVars = Omit, 'vars'>
73 |
74 | ${functions}
75 | }
76 | `
77 | }
78 |
79 | const targetSupport: { [key in TargetsSupported]: (chunks: MessageVariablesChunks) => string } = {
80 | vanilla: vanillaSupport,
81 | 'react-18next': i18NextSupport,
82 | 'fluent-react': fluentReactSupport,
83 | }
84 |
85 | const header = (target: TargetsSupported, chunks: MessageVariablesChunks) => {
86 | const messages = chunks
87 | .map((_, index) => dedent`
88 | type Message${index} = {
89 | id: T
90 | value: T
91 | attributes: Record
92 | }
93 | `)
94 | .join('\n\n')
95 |
96 | return dedent`
97 | ${messages}
98 |
99 | ${targetSupport[target](chunks)}
100 | `
101 | }
102 |
103 | const buildHeader = (target: TargetsSupported, chunks: MessageVariablesChunks) => `${bannerMessage}${header(target, chunks)}\n`
104 |
105 | export default buildHeader
106 |
--------------------------------------------------------------------------------
/src/build/index.ts:
--------------------------------------------------------------------------------
1 | import dedent from 'dedent-js'
2 | import buildHeader from './header'
3 | import buildTypeMessagesKey from './type-messages-key'
4 | import buildTypePatternArguments from './type-pattern-arguments'
5 | import { getMessagesVariables } from '../global-state'
6 | import { chunk } from '../helpers'
7 |
8 | const defaultChuckSize = 25
9 |
10 | const build = (target: TargetsSupported, { chuckSize = defaultChuckSize } = {}) => {
11 | const messagesVariables = getMessagesVariables()
12 | const messagesVariablesEntries = Object.entries(messagesVariables)
13 | const messagesVariablesChunks = chunk(messagesVariablesEntries, chuckSize)
14 |
15 | const fluentTypeModule = dedent`
16 | ${buildHeader(target, messagesVariablesChunks)}
17 | ${buildTypeMessagesKey(messagesVariablesChunks)}
18 | ${buildTypePatternArguments(messagesVariablesChunks)}
19 | `
20 |
21 | return fluentTypeModule
22 | }
23 |
24 | export default build
25 |
--------------------------------------------------------------------------------
/src/build/type-messages-key.ts:
--------------------------------------------------------------------------------
1 | import dedent from 'dedent-js'
2 |
3 | const buildTypeMessagesKey = (chunks: MessageVariablesChunks) =>
4 | chunks
5 | .map((subarray, index) => {
6 | const elements = subarray
7 | .map(([key]) => `'${key}'`)
8 | .join(' |\n')
9 |
10 | return dedent`
11 | type MessagesKey${index} = ${elements}
12 | `
13 | })
14 | .join('\n\n')
15 |
16 | export default buildTypeMessagesKey
17 |
--------------------------------------------------------------------------------
/src/build/type-pattern-arguments.ts:
--------------------------------------------------------------------------------
1 | import dedent from 'dedent-js'
2 |
3 | type Variables = MessageVariable[]
4 |
5 | const wrapVariables = (variables: Variables) => variables.map(i => `'${i}': FluentVariable`)
6 |
7 | const hasVariables = (variables: Variables) => variables.length > 0
8 |
9 | const buildTypePatternArguments = (chunks: MessageVariablesChunks) =>
10 | chunks
11 | .map((subarray, index) => {
12 | const elements = subarray
13 | .map(([message, variables]) => {
14 | if (hasVariables(variables)) {
15 | return dedent`
16 | T extends '${message}'
17 | ? [T, { ${wrapVariables(variables).join(',')} }]
18 | `
19 | }
20 |
21 | return dedent`
22 | T extends '${message}'
23 | ? [T]
24 | `
25 | })
26 | .join(':\n')
27 |
28 | return dedent`
29 | type PatternArguments${index} = (
30 | ${elements}
31 | : never
32 | )
33 | `
34 | })
35 | .join('\n\n')
36 |
37 | export default buildTypePatternArguments
38 |
--------------------------------------------------------------------------------
/src/global-state/index.ts:
--------------------------------------------------------------------------------
1 | import { FluentParser } from '@fluent/syntax'
2 | import { fromEntries } from '../helpers'
3 | import listMessagesVariables from './list-messages-variables'
4 |
5 | type GlobalState = {
6 | messages: {
7 | [ftlPath in string]: {
8 | [messageIdentifier in string]: MessageVariable[]
9 | }
10 | }
11 | }
12 |
13 | const initialState = (): GlobalState => ({
14 | messages: {},
15 | })
16 |
17 | let globalState = initialState()
18 |
19 | type UpdateGlobalStateParams = (
20 | { type: 'addContent', payload: CliFluentFile } |
21 | { type: 'updateContent', payload: CliFluentFile } |
22 | { type: 'reset' }
23 | )
24 | const updateGlobalState = (params: UpdateGlobalStateParams) => {
25 | if (params.type === 'addContent') {
26 | const { payload } = params
27 |
28 | const parser = new FluentParser({ withSpans: false })
29 | const ast = parser.parse(payload.content)
30 |
31 | Object.entries(listMessagesVariables(ast))
32 | .forEach(([name, variables]) => {
33 | if (globalState.messages[name] === undefined) {
34 | globalState.messages[name] = {}
35 | }
36 |
37 | globalState.messages[name][payload.path] = variables
38 | })
39 |
40 | return
41 | }
42 |
43 | if (params.type === 'updateContent') {
44 | const { payload } = params
45 |
46 | const parser = new FluentParser({ withSpans: false })
47 | const ast = parser.parse(payload.content)
48 |
49 | const messagesVariables = listMessagesVariables(ast)
50 |
51 | // Remove messages not used anymore
52 | const messagesNamesInUse = new Set(Object.keys(messagesVariables))
53 | Object.keys(globalState.messages)
54 | .forEach(name => {
55 | if (messagesNamesInUse.has(name) === false) {
56 | delete globalState.messages[name][payload.path]
57 |
58 | if (Object.keys(globalState.messages[name]).length === 0) {
59 | delete globalState.messages[name]
60 | }
61 | }
62 | })
63 |
64 | // Update messages
65 | Object.entries(messagesVariables)
66 | .forEach(([name, variables]) => {
67 | if (globalState.messages[name] === undefined) {
68 | globalState.messages[name] = {}
69 | }
70 |
71 | globalState.messages[name][payload.path] = variables
72 | })
73 |
74 | return
75 | }
76 |
77 | if (params.type === 'reset') {
78 | globalState = initialState()
79 | return
80 | }
81 | }
82 |
83 | const getMessagesVariables = (): MessageVariablesMap => {
84 | const entries = Object.entries(globalState.messages)
85 | .map(([message, filesToVariable]) => {
86 | const variablesSet = new Set(
87 | Object.values(filesToVariable).flat()
88 | )
89 | const variablesArray = Array
90 | .from(variablesSet)
91 | .sort()
92 |
93 | return [message, variablesArray] as [string, MessageVariable[]]
94 | })
95 |
96 | const messageVariables = fromEntries(entries)
97 |
98 | return messageVariables
99 | }
100 |
101 | export { updateGlobalState, getMessagesVariables }
102 |
--------------------------------------------------------------------------------
/src/global-state/list-messages-variables.ts:
--------------------------------------------------------------------------------
1 | import { Visitor, Message, VariableReference, Resource } from '@fluent/syntax'
2 |
3 | class VisitorListVariables extends Visitor {
4 | result: Set
5 |
6 | constructor() {
7 | super()
8 | this.result = new Set()
9 | }
10 |
11 | visitVariableReference(nodePlaceable: VariableReference) {
12 | this.result.add(nodePlaceable.id.name as MessageVariable)
13 | }
14 | }
15 |
16 | class VisitorListMessages extends Visitor {
17 | result: { [messageIdentifier in string]: MessageVariable[] }
18 |
19 | constructor() {
20 | super()
21 | this.result = {}
22 | }
23 |
24 | visitMessage(node: Message) {
25 | const visitorListVariables = new VisitorListVariables()
26 | visitorListVariables.visit(node)
27 | this.result[node.id.name] = [...visitorListVariables.result].sort()
28 | }
29 | }
30 |
31 | const listMessagesVariables = (ast: Resource) => {
32 | const visitorMessage = new VisitorListMessages()
33 | visitorMessage.visit(ast)
34 |
35 | return visitorMessage.result
36 | }
37 |
38 | export default listMessagesVariables
39 |
--------------------------------------------------------------------------------
/src/helpers.ts:
--------------------------------------------------------------------------------
1 | const fromEntries = >(iterable: T) =>
2 | [...iterable].reduce((obj, [key, val]) => {
3 | obj[key] = val
4 | return obj
5 | }, {} as { [key in string]: T[0][1] })
6 |
7 | const chunk = >(array: T, size: number): T[] =>
8 | Array
9 | .apply(0, { length: Math.ceil(array.length / size) })
10 | .map((_: unknown, index: number) => array.slice(index * size, (index + 1) * size))
11 |
12 | export { fromEntries, chunk }
13 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import build from './build'
2 | import { updateGlobalState } from './global-state'
3 |
4 | const start = (files: CliFluentFile[]) =>
5 | files.forEach(file => updateGlobalState({ type: 'addContent', payload: file }))
6 |
7 | const updateContent = (file: CliFluentFile) =>
8 | updateGlobalState({ type: 'updateContent', payload: file })
9 |
10 | const buildFluentTypeModule = build
11 |
12 | const targetsSupported: Array = ['vanilla', 'react-18next', 'fluent-react']
13 |
14 | // TODO: is necessary to export as common js to be available to cli.js
15 | // but probably there are better approach to do that
16 | module.exports = { start, updateContent, buildFluentTypeModule, targetsSupported }
17 |
--------------------------------------------------------------------------------
/src/types.d.ts:
--------------------------------------------------------------------------------
1 | type Tag = K & { __tag: T }
2 |
3 | type TargetsSupported = 'vanilla' | 'react-18next' | 'fluent-react'
4 |
5 | type MessageIdentifier = Tag
6 | type MessageVariable = Tag
7 | type MessageVariablesMap = {
8 | [messageIdentifier in string]: MessageVariable[]
9 | }
10 |
11 | type CliFluentFile = {
12 | path: string
13 | content: string
14 | }
15 |
16 | type MessageVariablesChunks = [string, MessageVariable[]][][]
17 |
--------------------------------------------------------------------------------
/test/global-state.test.ts:
--------------------------------------------------------------------------------
1 | import dedent from 'dedent-js'
2 | import { updateGlobalState, getMessagesVariables } from '../src/global-state'
3 |
4 | describe('global-state', () => {
5 | afterEach(() => updateGlobalState({ type: 'reset' }))
6 |
7 | test('Should can initialize', () => {
8 | const fixturePt: CliFluentFile = {
9 | path: 'pt.ftl',
10 | content: dedent`
11 | hello = Olá { $firstName }
12 | how-are-you = Como você está?
13 | bye = Tchau
14 | `,
15 | }
16 | const fixtureJp: CliFluentFile = {
17 | path: 'jp.ftl',
18 | content: dedent`
19 | hello = こんにちは{ $lastName }
20 | how-are-you = お元気ですか?
21 | `,
22 | }
23 |
24 | updateGlobalState({ type: 'addContent', payload: fixturePt })
25 | const messagesVariablesPt = getMessagesVariables()
26 | expect(messagesVariablesPt).toEqual({
27 | hello: ['firstName'],
28 | 'how-are-you': [],
29 | bye: [],
30 | })
31 |
32 | updateGlobalState({ type: 'addContent', payload: fixtureJp })
33 | const messagesVariablesPtJp = getMessagesVariables()
34 | expect(messagesVariablesPtJp).toEqual({
35 | hello: ['firstName', 'lastName'],
36 | 'how-are-you': [],
37 | bye: [],
38 | })
39 | })
40 |
41 | test('Should update message variables', () => {
42 | const fixturePt: CliFluentFile = {
43 | path: 'pt.ftl',
44 | content: dedent`
45 | hello = Olá { $name }
46 | `,
47 | }
48 | const fixturePtSecondVersion: CliFluentFile = {
49 | path: 'pt.ftl',
50 | content: dedent`
51 | hello = Olá { $firstName }
52 | `,
53 | }
54 |
55 | updateGlobalState({ type: 'addContent', payload: fixturePt })
56 | const messagesVariablesPt = getMessagesVariables()
57 | expect(messagesVariablesPt).toEqual({
58 | hello: ['name'],
59 | })
60 |
61 | updateGlobalState({ type: 'updateContent', payload: fixturePtSecondVersion })
62 | const messagesVariablesPtSecondVersion = getMessagesVariables()
63 | expect(messagesVariablesPtSecondVersion).toEqual({
64 | hello: ['firstName'],
65 | })
66 | })
67 |
68 | test('Should remove message not used anymore', () => {
69 | const fixturePt: CliFluentFile = {
70 | path: 'pt.ftl',
71 | content: dedent`
72 | hello = Olá { $name }
73 | how-are-you = Como você está?
74 | `,
75 | }
76 | const fixturePtSecondVersion: CliFluentFile = {
77 | path: 'pt.ftl',
78 | content: dedent`
79 | hello = Olá { $name }
80 | `,
81 | }
82 |
83 | updateGlobalState({ type: 'addContent', payload: fixturePt })
84 | const messagesVariablesPt = getMessagesVariables()
85 | expect(messagesVariablesPt).toEqual({
86 | hello: ['name'],
87 | 'how-are-you': [],
88 | })
89 |
90 | updateGlobalState({ type: 'updateContent', payload: fixturePtSecondVersion })
91 | const messagesVariablesPtSecondVersion = getMessagesVariables()
92 | expect(messagesVariablesPtSecondVersion).toEqual({
93 | hello: ['name'],
94 | })
95 | })
96 |
97 | test('Should keep message if is still used on some language', () => {
98 | const fixturePt: CliFluentFile = {
99 | path: 'pt.ftl',
100 | content: dedent`
101 | hello = Olá { $firstName }
102 | how-are-you = Como você está?
103 | `,
104 | }
105 | const fixturePtSecondVersion: CliFluentFile = {
106 | path: 'pt.ftl',
107 | content: dedent`
108 | hello = Olá { $firstName }
109 | `,
110 | }
111 | const fixtureJp: CliFluentFile = {
112 | path: 'jp.ftl',
113 | content: dedent`
114 | hello = こんにちは{ $lastName }
115 | how-are-you = お元気ですか?
116 | `,
117 | }
118 |
119 | updateGlobalState({ type: 'addContent', payload: fixturePt })
120 | updateGlobalState({ type: 'addContent', payload: fixtureJp })
121 | const messagesVariablesPtJp = getMessagesVariables()
122 | expect(messagesVariablesPtJp).toEqual({
123 | hello: ['firstName', 'lastName'],
124 | 'how-are-you': [],
125 | })
126 |
127 | updateGlobalState({ type: 'updateContent', payload: fixturePtSecondVersion })
128 | const messagesVariablesPtSecondVersionJp = getMessagesVariables()
129 | expect(messagesVariablesPtSecondVersionJp).toEqual({
130 | hello: ['firstName', 'lastName'],
131 | 'how-are-you': [],
132 | })
133 | })
134 |
135 | test('Should can read variables on a selector', () => {
136 | const fixtureEn: CliFluentFile = {
137 | path: 'en.ftl',
138 | content: dedent`
139 | emails =
140 | { $unreadEmails ->
141 | [one] { $name } has one unread email.
142 | *[other] { $name } has { $unreadEmails } unread emails.
143 | }
144 | `,
145 | }
146 |
147 | updateGlobalState({ type: 'addContent', payload: fixtureEn })
148 | const messagesVariablesPt = getMessagesVariables()
149 | expect(messagesVariablesPt).toEqual({
150 | emails: ['name', 'unreadEmails'],
151 | })
152 | })
153 |
154 | test('Should can read variables on a built-in function', () => {
155 | const fixtureEn: CliFluentFile = {
156 | path: 'en.ftl',
157 | content: dedent`
158 | time-elapsed = Time elapsed: { NUMBER($duration, maximumFractionDigits: 0) }s.
159 | `,
160 | }
161 |
162 | updateGlobalState({ type: 'addContent', payload: fixtureEn })
163 | const messagesVariablesPt = getMessagesVariables()
164 | expect(messagesVariablesPt).toEqual({
165 | 'time-elapsed': ['duration'],
166 | })
167 | })
168 | })
169 |
--------------------------------------------------------------------------------
/test/index.test.ts:
--------------------------------------------------------------------------------
1 | import dedent from 'dedent-js'
2 | import { updateGlobalState } from '../src/global-state'
3 | const { start, updateContent, buildFluentTypeModule } = require('../src')
4 |
5 | describe('With vanilla target', () => {
6 | afterEach(() => updateGlobalState({ type: 'reset' }))
7 |
8 | test('Should match the types definitions', async () => {
9 | const fixturePtFirstVersion = dedent`
10 | hello = Olá { $firstName }
11 | how-are-you = Como você está?
12 | bye = Tchau
13 | `
14 | const fixtureJp = dedent`
15 | hello = こんにちは{ $lastName }
16 | how-are-you = お元気ですか?
17 | `
18 |
19 | start([
20 | { path: 'pt.ftl', content: fixturePtFirstVersion },
21 | { path: 'jp.ftl', content: fixtureJp },
22 | ])
23 |
24 | const fluentTypeModuleFirstVersion = buildFluentTypeModule('vanilla')
25 | expect(fluentTypeModuleFirstVersion).toBe(dedent`
26 | // This file is automatically generated.
27 | // Please do not change this file!
28 |
29 | type Message0 = {
30 | id: T
31 | value: T
32 | attributes: Record
33 | }
34 |
35 | import { FluentBundle, FluentVariable } from '@fluent/bundle'
36 |
37 | declare global {
38 | interface FluentBundleTyped extends FluentBundle {
39 | getMessage(id: T): Message0
40 | formatPattern(...args: PatternArguments0): string
41 | }
42 | }
43 |
44 | type MessagesKey0 = 'hello' |
45 | 'how-are-you' |
46 | 'bye'
47 | type PatternArguments0 = (
48 | T extends 'hello'
49 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
50 | T extends 'how-are-you'
51 | ? [T]:
52 | T extends 'bye'
53 | ? [T]
54 | : never
55 | )
56 | `)
57 |
58 | const fixturePtSecondVersion = dedent`
59 | hello = Olá { $firstName }
60 | how-are-you = Como você está?
61 | `
62 | updateContent({ path: 'pt.ftl', content: fixturePtSecondVersion })
63 |
64 | const fluentTypeModuleSecondVersion = buildFluentTypeModule('vanilla')
65 | expect(fluentTypeModuleSecondVersion).toBe(dedent`
66 | // This file is automatically generated.
67 | // Please do not change this file!
68 |
69 | type Message0 = {
70 | id: T
71 | value: T
72 | attributes: Record
73 | }
74 |
75 | import { FluentBundle, FluentVariable } from '@fluent/bundle'
76 |
77 | declare global {
78 | interface FluentBundleTyped extends FluentBundle {
79 | getMessage(id: T): Message0
80 | formatPattern(...args: PatternArguments0): string
81 | }
82 | }
83 |
84 | type MessagesKey0 = 'hello' |
85 | 'how-are-you'
86 | type PatternArguments0 = (
87 | T extends 'hello'
88 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
89 | T extends 'how-are-you'
90 | ? [T]
91 | : never
92 | )
93 | `)
94 | })
95 |
96 | test('Should match when split the messages', async () => {
97 | const fixture = dedent`
98 | message0 = foo
99 | message1 = foo
100 | message2 = foo
101 | message3 = foo
102 | message4 = foo
103 | `
104 |
105 | start([
106 | { path: 'pt.ftl', content: fixture },
107 | ])
108 |
109 | const fluentTypeModule = buildFluentTypeModule('vanilla', { chuckSize: 3 })
110 | expect(fluentTypeModule).toBe(dedent`
111 | // This file is automatically generated.
112 | // Please do not change this file!
113 |
114 | type Message0 = {
115 | id: T
116 | value: T
117 | attributes: Record
118 | }
119 |
120 | type Message1 = {
121 | id: T
122 | value: T
123 | attributes: Record
124 | }
125 |
126 | import { FluentBundle, FluentVariable } from '@fluent/bundle'
127 |
128 | declare global {
129 | interface FluentBundleTyped extends FluentBundle {
130 | getMessage(id: T): Message0
131 | formatPattern(...args: PatternArguments0): string
132 | getMessage(id: T): Message1
133 | formatPattern(...args: PatternArguments1): string
134 | }
135 | }
136 |
137 | type MessagesKey0 = 'message0' |
138 | 'message1' |
139 | 'message2'
140 |
141 | type MessagesKey1 = 'message3' |
142 | 'message4'
143 | type PatternArguments0 = (
144 | T extends 'message0'
145 | ? [T]:
146 | T extends 'message1'
147 | ? [T]:
148 | T extends 'message2'
149 | ? [T]
150 | : never
151 | )
152 |
153 | type PatternArguments1 = (
154 | T extends 'message3'
155 | ? [T]:
156 | T extends 'message4'
157 | ? [T]
158 | : never
159 | )
160 | `)
161 | })
162 | })
163 |
164 | describe('With react-18next target', () => {
165 | afterEach(() => updateGlobalState({ type: 'reset' }))
166 |
167 | test('Should match the types definitions', async () => {
168 | const fixturePtFirstVersion = dedent`
169 | hello = Olá { $firstName }
170 | how-are-you = Como você está?
171 | bye = Tchau
172 | `
173 | const fixtureJp = dedent`
174 | hello = こんにちは{ $lastName }
175 | how-are-you = お元気ですか?
176 | `
177 |
178 | start([
179 | { path: 'pt.ftl', content: fixturePtFirstVersion },
180 | { path: 'jp.ftl', content: fixtureJp },
181 | ])
182 |
183 | const fluentTypeModuleFirstVersion = buildFluentTypeModule('react-18next')
184 | expect(fluentTypeModuleFirstVersion).toBe(dedent`
185 | // This file is automatically generated.
186 | // Please do not change this file!
187 |
188 | type Message0 = {
189 | id: T
190 | value: T
191 | attributes: Record
192 | }
193 |
194 | import { FluentVariable } from '@fluent/bundle'
195 |
196 | declare module 'react-i18next' {
197 | interface UseTranslationResponsePatched extends Omit {
198 | t(...args: PatternArguments0): string
199 | }
200 |
201 | function useTranslation(ns?: Namespace, options?: UseTranslationOptions): UseTranslationResponsePatched
202 | }
203 |
204 | type MessagesKey0 = 'hello' |
205 | 'how-are-you' |
206 | 'bye'
207 | type PatternArguments0 = (
208 | T extends 'hello'
209 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
210 | T extends 'how-are-you'
211 | ? [T]:
212 | T extends 'bye'
213 | ? [T]
214 | : never
215 | )
216 | `)
217 |
218 | const fixturePtSecondVersion = dedent`
219 | hello = Olá { $firstName }
220 | how-are-you = Como você está?
221 | `
222 | updateContent({ path: 'pt.ftl', content: fixturePtSecondVersion })
223 |
224 | const fluentTypeModuleSecondVersion = buildFluentTypeModule('react-18next')
225 | expect(fluentTypeModuleSecondVersion).toBe(dedent`
226 | // This file is automatically generated.
227 | // Please do not change this file!
228 |
229 | type Message0 = {
230 | id: T
231 | value: T
232 | attributes: Record
233 | }
234 |
235 | import { FluentVariable } from '@fluent/bundle'
236 |
237 | declare module 'react-i18next' {
238 | interface UseTranslationResponsePatched extends Omit {
239 | t(...args: PatternArguments0): string
240 | }
241 |
242 | function useTranslation(ns?: Namespace, options?: UseTranslationOptions): UseTranslationResponsePatched
243 | }
244 |
245 | type MessagesKey0 = 'hello' |
246 | 'how-are-you'
247 | type PatternArguments0 = (
248 | T extends 'hello'
249 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
250 | T extends 'how-are-you'
251 | ? [T]
252 | : never
253 | )
254 | `)
255 | })
256 |
257 | test('Should match when split the messages', async () => {
258 | const fixture = dedent`
259 | message0 = foo
260 | message1 = foo
261 | message2 = foo
262 | message3 = foo
263 | message4 = foo
264 | `
265 |
266 | start([
267 | { path: 'pt.ftl', content: fixture },
268 | ])
269 |
270 | const fluentTypeModule = buildFluentTypeModule('react-18next', { chuckSize: 3 })
271 | expect(fluentTypeModule).toBe(dedent`
272 | // This file is automatically generated.
273 | // Please do not change this file!
274 |
275 | type Message0 = {
276 | id: T
277 | value: T
278 | attributes: Record
279 | }
280 |
281 | type Message1 = {
282 | id: T
283 | value: T
284 | attributes: Record
285 | }
286 |
287 | import { FluentVariable } from '@fluent/bundle'
288 |
289 | declare module 'react-i18next' {
290 | interface UseTranslationResponsePatched extends Omit {
291 | t(...args: PatternArguments0): string
292 | t(...args: PatternArguments1): string
293 | }
294 |
295 | function useTranslation(ns?: Namespace, options?: UseTranslationOptions): UseTranslationResponsePatched
296 | }
297 |
298 | type MessagesKey0 = 'message0' |
299 | 'message1' |
300 | 'message2'
301 |
302 | type MessagesKey1 = 'message3' |
303 | 'message4'
304 | type PatternArguments0 = (
305 | T extends 'message0'
306 | ? [T]:
307 | T extends 'message1'
308 | ? [T]:
309 | T extends 'message2'
310 | ? [T]
311 | : never
312 | )
313 |
314 | type PatternArguments1 = (
315 | T extends 'message3'
316 | ? [T]:
317 | T extends 'message4'
318 | ? [T]
319 | : never
320 | )
321 | `)
322 | })
323 | })
324 |
325 | describe('With fluent-react target', () => {
326 | afterEach(() => updateGlobalState({ type: 'reset' }))
327 |
328 | test('Should match the types definitions', async () => {
329 | const fixturePtFirstVersion = dedent`
330 | hello = Olá { $firstName }
331 | how-are-you = Como você está?
332 | bye = Tchau
333 | `
334 | const fixtureJp = dedent`
335 | hello = こんにちは{ $lastName }
336 | how-are-you = お元気ですか?
337 | `
338 |
339 | start([
340 | { path: 'pt.ftl', content: fixturePtFirstVersion },
341 | { path: 'jp.ftl', content: fixtureJp },
342 | ])
343 |
344 | const fluentTypeModuleFirstVersion = buildFluentTypeModule('fluent-react')
345 | expect(fluentTypeModuleFirstVersion).toBe(dedent`
346 | // This file is automatically generated.
347 | // Please do not change this file!
348 |
349 | type Message0 = {
350 | id: T
351 | value: T
352 | attributes: Record
353 | }
354 |
355 | import { FluentVariable } from '@fluent/bundle'
356 | import { LocalizedProps } from '@fluent/react'
357 | import { ReactElement } from 'react'
358 |
359 | declare module '@fluent/react' {
360 | type LocalizedPropsWithoutIdAndVars = Omit, 'vars'>
361 |
362 | type LocalizedPropsPatched0 = (
363 | PatternArguments0[1] extends undefined
364 | ? {
365 | typed: true
366 | id: T
367 | } & LocalizedPropsWithoutIdAndVars
368 | : {
369 | typed: true
370 | id: T
371 | vars: PatternArguments0[1]
372 | } & LocalizedPropsWithoutIdAndVars
373 | )
374 |
375 | function Localized(props: LocalizedPropsPatched0): ReactElement;
376 | }
377 |
378 | type MessagesKey0 = 'hello' |
379 | 'how-are-you' |
380 | 'bye'
381 | type PatternArguments0 = (
382 | T extends 'hello'
383 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
384 | T extends 'how-are-you'
385 | ? [T]:
386 | T extends 'bye'
387 | ? [T]
388 | : never
389 | )
390 | `)
391 |
392 | const fixturePtSecondVersion = dedent`
393 | hello = Olá { $firstName }
394 | how-are-you = Como você está?
395 | `
396 | updateContent({ path: 'pt.ftl', content: fixturePtSecondVersion })
397 |
398 | const fluentTypeModuleSecondVersion = buildFluentTypeModule('fluent-react')
399 | expect(fluentTypeModuleSecondVersion).toBe(dedent`
400 | // This file is automatically generated.
401 | // Please do not change this file!
402 |
403 | type Message0 = {
404 | id: T
405 | value: T
406 | attributes: Record
407 | }
408 |
409 | import { FluentVariable } from '@fluent/bundle'
410 | import { LocalizedProps } from '@fluent/react'
411 | import { ReactElement } from 'react'
412 |
413 | declare module '@fluent/react' {
414 | type LocalizedPropsWithoutIdAndVars = Omit, 'vars'>
415 |
416 | type LocalizedPropsPatched0 = (
417 | PatternArguments0[1] extends undefined
418 | ? {
419 | typed: true
420 | id: T
421 | } & LocalizedPropsWithoutIdAndVars
422 | : {
423 | typed: true
424 | id: T
425 | vars: PatternArguments0[1]
426 | } & LocalizedPropsWithoutIdAndVars
427 | )
428 |
429 | function Localized(props: LocalizedPropsPatched0): ReactElement;
430 | }
431 |
432 | type MessagesKey0 = 'hello' |
433 | 'how-are-you'
434 | type PatternArguments0 = (
435 | T extends 'hello'
436 | ? [T, { 'firstName': FluentVariable,'lastName': FluentVariable }]:
437 | T extends 'how-are-you'
438 | ? [T]
439 | : never
440 | )
441 | `)
442 | })
443 |
444 | test('Should match when split the messages', async () => {
445 | const fixture = dedent`
446 | message0 = foo
447 | message1 = foo
448 | message2 = foo
449 | message3 = foo
450 | message4 = foo
451 | `
452 |
453 | start([
454 | { path: 'pt.ftl', content: fixture },
455 | ])
456 |
457 | const fluentTypeModule = buildFluentTypeModule('fluent-react', { chuckSize: 3 })
458 | expect(fluentTypeModule).toBe(dedent`
459 | // This file is automatically generated.
460 | // Please do not change this file!
461 |
462 | type Message0 = {
463 | id: T
464 | value: T
465 | attributes: Record
466 | }
467 |
468 | type Message1 = {
469 | id: T
470 | value: T
471 | attributes: Record
472 | }
473 |
474 | import { FluentVariable } from '@fluent/bundle'
475 | import { LocalizedProps } from '@fluent/react'
476 | import { ReactElement } from 'react'
477 |
478 | declare module '@fluent/react' {
479 | type LocalizedPropsWithoutIdAndVars = Omit, 'vars'>
480 |
481 | type LocalizedPropsPatched0 = (
482 | PatternArguments0[1] extends undefined
483 | ? {
484 | typed: true
485 | id: T
486 | } & LocalizedPropsWithoutIdAndVars
487 | : {
488 | typed: true
489 | id: T
490 | vars: PatternArguments0[1]
491 | } & LocalizedPropsWithoutIdAndVars
492 | )
493 |
494 | function Localized(props: LocalizedPropsPatched0): ReactElement;
495 | type LocalizedPropsPatched1 = (
496 | PatternArguments1[1] extends undefined
497 | ? {
498 | typed: true
499 | id: T
500 | } & LocalizedPropsWithoutIdAndVars
501 | : {
502 | typed: true
503 | id: T
504 | vars: PatternArguments1[1]
505 | } & LocalizedPropsWithoutIdAndVars
506 | )
507 |
508 | function Localized(props: LocalizedPropsPatched1): ReactElement;
509 | }
510 |
511 | type MessagesKey0 = 'message0' |
512 | 'message1' |
513 | 'message2'
514 |
515 | type MessagesKey1 = 'message3' |
516 | 'message4'
517 | type PatternArguments0 = (
518 | T extends 'message0'
519 | ? [T]:
520 | T extends 'message1'
521 | ? [T]:
522 | T extends 'message2'
523 | ? [T]
524 | : never
525 | )
526 |
527 | type PatternArguments1 = (
528 | T extends 'message3'
529 | ? [T]:
530 | T extends 'message4'
531 | ? [T]
532 | : never
533 | )
534 | `)
535 | })
536 | })
537 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": ["./example-react-18next", "./example-vanilla", "./example-fluent-react"],
3 | "compilerOptions": {
4 | "allowSyntheticDefaultImports": true,
5 | "noImplicitAny": true,
6 | "moduleResolution": "node",
7 | "module": "es6",
8 | "target": "es6",
9 | "strictNullChecks": true,
10 | "rootDirs": ["src", "test"],
11 | "esModuleInterop": true,
12 | "allowJs": false,
13 | "lib": ["es2019"]
14 | }
15 | }
16 |
--------------------------------------------------------------------------------