├── .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 | 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 | --------------------------------------------------------------------------------