├── .gitattributes ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── CHANGELOG.md ├── README.md ├── docs ├── .gitignore ├── markdown │ ├── about.md │ ├── configuration.md │ ├── getting-started.md │ ├── icu-message-format.md │ ├── manifest.json │ └── usage.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── docs │ │ └── [slug].tsx │ ├── index.tsx │ └── playground.tsx ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ └── favicon.ico ├── src │ ├── app.css │ ├── components │ │ ├── CodeBlock.tsx │ │ ├── Header.tsx │ │ ├── Layout.tsx │ │ ├── Link.tsx │ │ ├── Playground │ │ │ ├── CodeDialog.tsx │ │ │ ├── Editor.tsx │ │ │ └── index.tsx │ │ └── nav.tsx │ ├── constants.ts │ ├── highlight.tsx │ └── theme.ts └── tsconfig.json ├── examples └── next.js │ ├── .gitignore │ ├── README.md │ ├── components │ └── nav.js │ ├── locales │ ├── en │ │ └── strings.json │ ├── fr │ │ └── strings.json │ └── nl │ │ └── strings.json │ ├── next.config.js │ ├── now.json │ ├── package.json │ ├── pages │ └── index.js │ ├── public │ └── favicon.ico │ ├── scripts │ ├── build.js │ ├── deploy.js │ └── start.js │ └── yarn.lock ├── lerna.json ├── now.json ├── package.json ├── packages ├── codemirror-mode-icu │ ├── .babelrc.js │ ├── .gitignore │ ├── example.html │ ├── mode.css │ ├── package.json │ ├── src │ │ ├── index.test.ts │ │ └── index.ts │ └── tsconfig.json └── nymus │ ├── .babelrc.js │ ├── README.md │ ├── cli.js │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── Module.ts │ ├── Scope.ts │ ├── TransformationError.ts │ ├── __fixtures__ │ │ ├── invalid-json.json │ │ ├── invalid-message.json │ │ └── strings │ │ │ ├── en.json │ │ │ ├── fr.json │ │ │ └── nl.json │ ├── __snapshots__ │ │ └── index.test.ts.snap │ ├── astUtil.ts │ ├── cli.test.ts │ ├── cli.ts │ ├── createComponent.ts │ ├── fileUtil.ts │ ├── formats.ts │ ├── index.test.ts │ ├── index.ts │ ├── testUtils.ts │ ├── types │ │ └── babel__plugin-transform-typescript.ts │ ├── webpack.test.ts │ └── webpack.ts │ ├── tsconfig.json │ ├── webpack.d.ts │ └── webpack.js ├── tsconfig.json └── yarn.lock /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: nymus tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [10.x, 12.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v1 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | 20 | # yarn caches 21 | - name: Get yarn cache dir 22 | id: yarn-cache 23 | run: echo "::set-output name=dir::$(yarn cache dir)" 24 | - name: Restore yarn cache 25 | uses: actions/cache@v1 26 | with: 27 | path: ${{ steps.yarn-cache.outputs.dir }} 28 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-yarn- 31 | 32 | # Node full ICU support (needed for node.js < 12) 33 | - name: Get ICU cache dir 34 | id: icu-cache 35 | run: echo "::set-output name=dir::$(npm root -g)/full-icu" 36 | - name: Restore ICU data cache 37 | uses: actions/cache@v1 38 | id: full-icu-cache 39 | with: 40 | path: ${{ steps.icu-cache.outputs.dir }} 41 | key: ${{ runner.os }}-node-${{ matrix.node-version }}-icu 42 | - name: Download ICU data 43 | id: full-icu 44 | if: steps.full-icu-cache.outputs.cache-hit != 'true' 45 | run: npm install -g full-icu 46 | 47 | - name: Install dependencies 48 | run: yarn install --frozen-lockfile 49 | 50 | - name: Check formatting 51 | run: yarn prettier 52 | 53 | - name: Build packages 54 | run: yarn build 55 | env: 56 | CI: true 57 | 58 | - name: Run tests 59 | run: yarn test 60 | env: 61 | CI: true 62 | TZ: Europe/Brussels 63 | NODE_ICU_DATA: ${{ steps.icu-cache.outputs.dir }} 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | .now 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | .next 3 | __fixtures__ 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## nymus 4 | 5 | ### 0.3.0 6 | 7 | - BREAKING: output is now JSX, so will need to be run through `@babel/preset-react`. 8 | - BREAKING: remove `react: boolean` option and replace with `target: 'react' | 'string'`. 9 | 10 | ### 0.2.0 11 | 12 | - BREAKING: remove support for self-closing tags. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

🦉 nymus

2 |

3 | 4 | Version 5 | 6 | 7 | License: MIT 8 | 9 | 10 | nymus test status 11 | 12 |

13 | 14 | > Transform [ICU messages](http://userguide.icu-project.org/formatparse/messages) into React components. 15 | 16 | ## Usage 17 | 18 | ### Example 19 | 20 | ```sh 21 | npx nymus ./messages.json 22 | ``` 23 | 24 | given a `./messages.json` file: 25 | 26 | ```json 27 | { 28 | "Welcome": "It's {name}, {gender, select, male {his} female {her} other {their}} birthday is {birthday, date, long}" 29 | } 30 | ``` 31 | 32 | `nymus` will generate a module containing React components that can be readily imported in your project as follows: 33 | 34 | ```js 35 | import * as React from 'react'; 36 | import { Welcome } from './messages'; 37 | 38 | export function HomePage() { 39 | return ; 40 | } 41 | ``` 42 | 43 | ## Documentation 44 | 45 | - [playground](https://nymus.now.sh/playground) 46 | - [documentation](https://nymus.now.sh/docs) 47 | 48 | ## Author 49 | 50 | 👤 **Jan Potoms** 51 | 52 | - Github: [@janpot](https://github.com/janpot) 53 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /docs/markdown/about.md: -------------------------------------------------------------------------------- 1 | # About `nymus` 2 | 3 | ## Philosophy 4 | 5 | I strongly believe that the internationalization of an application should impose as little runtime overhead as possible. this means that: 6 | 7 | 1. No more message strings should be loaded than are being used by any given javascript bundle. 8 | 2. No more parsing or transformation should be needed before a string can be used as compared to a hand-written component. 9 | 10 | To achieve the first criterium, `nymus` relies on [tree-shaking](https://webpack.js.org/guides/tree-shaking/). It will generate tree-shakeable component files in ES6 module format. It relies on existing javascript tools, like webpack, to eliminate unused code from the bundles. 11 | 12 | To achieve the second criterium, `nymus` will translate each ICU message into a highly optimized React component. All parsing is done at build time, and the component generates strings with just string templateng. To illustrate: 13 | 14 | ```json 15 | { 16 | "Message": "Hello there, {name}, your score is {score, number, percent}." 17 | } 18 | ``` 19 | 20 | Should result into code equivalent to 21 | 22 | ```js 23 | const number = new Intl.NumberFormat('en', { style: 'percent' }); 24 | export function Message({ name, score }) { 25 | return `Hello there, ${name}, your score is ${number.format(score)}.`; 26 | } 27 | ``` 28 | 29 | To get an idea of how `nymus` translates messages, head over to [the playground](/playground) to try it for yourself. 30 | 31 | ## Acknowledgements 32 | 33 | This project wouldn't be possible without the following libraries: 34 | 35 | - [formatjs](https://formatjs.io/github/) 36 | - [typecript](https://www.typescriptlang.org/) 37 | - [babel](https://babeljs.io/) 38 | -------------------------------------------------------------------------------- /docs/markdown/configuration.md: -------------------------------------------------------------------------------- 1 | # configuration 2 | 3 | `nymus` can be configured to your needs 4 | 5 | ## locale 6 | 7 | Configures the locale to be used for the `Intl.*` formatters and pluralrules. The locale is directly inserted in the constructor of these objectes. e.g. 8 | 9 | ```sh 10 | nymus --locale ru ./ru/strings.json 11 | ``` 12 | 13 | By using the `--locale` option, the generated components will use formatters that are constructed like so: 14 | 15 | ```js 16 | const numberFormat = new Intl.NumberFormat('ru'); 17 | ``` 18 | 19 | ## typescript 20 | 21 | Emit typescript files instead of javascript 22 | 23 | ```sh 24 | nymus --typescript ./strings.json 25 | ``` 26 | 27 | This will result in the creaton of a `.ts` file `./strings.ts` containing typed components for the strings in `./strings.json`. 28 | 29 | ## `declarations` 30 | 31 | Emit `.d.ts` declaration files next to the messageformat files. 32 | 33 | ```sh 34 | nymus --declarations ./strings.json 35 | ``` 36 | 37 | This will result in the creaton of a `.d.ts` file `./strings.d.ts` containing declarations for the componets in `./strings.js`. 38 | 39 | **Important!:** `nymus` only has a `peerDependency` on `typescript`. In order for this function to work, a `typescript` compiler needs to be installed in your project. 40 | -------------------------------------------------------------------------------- /docs/markdown/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | Define ICU messages in a JSON file: 4 | 5 | ```json 6 | { 7 | "WelcomeMessage": "Hi there, {name}. Welcome to {place}.", 8 | "Notification": "You have {count, plural, one {a notification} other {# notifications}}", 9 | "Copyright": "© {date, date} {organization}" 10 | } 11 | ``` 12 | 13 | Run the `nymus` CLI to generate components: 14 | 15 | ```sh 16 | nymus --locale en ./en/strings.json 17 | ``` 18 | 19 | Import the components in your project: 20 | 21 | ```js 22 | import { WelcomeMessage } from './en/strings'; 23 | ``` 24 | 25 | Use the generated component: 26 | 27 | ```jsx 28 | 29 | ``` 30 | 31 | ## Examples 32 | 33 | Take alook at the examples to get an idea of how a complete setup would work 34 | 35 | - [with next.js](https://github.com/Janpot/nymus/tree/master/examples/next.js) 36 | -------------------------------------------------------------------------------- /docs/markdown/icu-message-format.md: -------------------------------------------------------------------------------- 1 | # ICU MEssage format 2 | 3 | Read [the formatjs.io guide](https://formatjs.io/guides/message-syntax/) to get familiar quickly with the syntax. `nymus` adds a few interesting features on top: 4 | 5 | ## JSX 6 | 7 | `nymus` supports JSX tags inside strings. This is useful to inject extra behavior into messages. 8 | 9 | ```json 10 | { 11 | "MoreInfo": "Find more information on our about page." 12 | } 13 | ``` 14 | 15 | ```jsx 16 | {children}} /> 17 | ``` 18 | 19 | Attributes and self-closing elements are not allowed and `nymus` will throw an error if they are used. 20 | -------------------------------------------------------------------------------- /docs/markdown/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "routes": [ 3 | { 4 | "title": "Getting Started", 5 | "path": "getting-started" 6 | }, 7 | { 8 | "title": "Usage", 9 | "path": "usage" 10 | }, 11 | { 12 | "title": "ICU message format", 13 | "path": "icu-message-format" 14 | }, 15 | { 16 | "title": "Configuration", 17 | "path": "configuration" 18 | }, 19 | { 20 | "title": "About", 21 | "path": "about" 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /docs/markdown/usage.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | ## Installation 4 | 5 | Install the `nymus` package with `npm`: 6 | 7 | ```sh 8 | npm install --save-dev nymus 9 | ``` 10 | 11 | `typescript` is also needed if `.d.ts` files are to be generated. 12 | 13 | ## webpack 14 | 15 | webpack can be configured to compile certain JSON files with `nymus` as follows: 16 | 17 | ```js 18 | module.exports = { 19 | // ... 20 | module: { 21 | rules: [ 22 | // ... 23 | { 24 | test: /\.json$/, 25 | include: [path.resolve(__dirname, './locales/')], 26 | type: 'javascript/auto', 27 | use: [ 28 | { 29 | loader: 'nymus/webpack', 30 | options: { locale }, 31 | }, 32 | ], 33 | }, 34 | ], 35 | }, 36 | }; 37 | ``` 38 | 39 | ## CLI 40 | 41 | ```sh 42 | nymus [...options] [...files] 43 | ``` 44 | 45 | Use the `--help` option to get an overview of the [available options](/docs/configuration). 46 | 47 | ## API 48 | 49 | ```js 50 | import createModule from 'nymus'; 51 | 52 | const MESSAGES = { 53 | Welcome: 'Hi there, {name}', 54 | }; 55 | 56 | createModule(MESSAGES).then(({ code }) => console.log(code)); 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | declare module '@mdx-js/runtime' { 5 | import { FunctionComponent } from 'react'; 6 | import { Options } from '@mdx-js/mdx'; 7 | import { ComponentsProp } from '@mdx-js/react'; 8 | 9 | /** 10 | * Properties for the MDX Runtime component 11 | */ 12 | export interface MDXRuntimeProps 13 | extends Omit, 14 | ComponentsProp { 15 | /** 16 | * MDX text 17 | */ 18 | children: string; 19 | 20 | /** 21 | * Values in usable in MDX scope 22 | */ 23 | scope: { 24 | [variableName: string]: unknown; 25 | }; 26 | } 27 | 28 | /** 29 | * Renders child MDX text as a React component 30 | */ 31 | declare const mdxRuntime: FunctionComponent>; 32 | 33 | export default mdxRuntime; 34 | } 35 | 36 | declare module '@babel/plugin-transform-typescript' { 37 | import { PluginItem } from '@babel/core'; 38 | const plugin: PluginItem; 39 | export default plugin; 40 | } 41 | 42 | declare module '@babel/plugin-transform-modules-commonjs' { 43 | import { PluginItem } from '@babel/core'; 44 | const plugin: PluginItem; 45 | export default plugin; 46 | } 47 | 48 | declare module '@babel/plugin-transform-react-jsx' { 49 | import { PluginItem } from '@babel/core'; 50 | const plugin: PluginItem; 51 | export default plugin; 52 | } 53 | 54 | declare module '@babel/standalone' { 55 | import { BabelFileResult, TransformOptions, Node } from '@babel/core'; 56 | export function transform( 57 | code: string, 58 | options: TransformOptions 59 | ): BabelFileResult; 60 | export function transformFromAst( 61 | ast: Node, 62 | code?: string, 63 | options: TransformOptions 64 | ): BabelFileResult; 65 | } 66 | 67 | // this can be removed when @types/prettier have updated to take the 68 | // babel => babylon rename into account 69 | declare module 'prettier/parser-babel' { 70 | import plugin from 'prettier/parser-babylon'; 71 | export = plugin; 72 | } 73 | -------------------------------------------------------------------------------- /docs/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | typescript: { 3 | ignoreDevErrors: true, 4 | }, 5 | experimental: { 6 | polyfillsOptimization: true, 7 | redirects: async () => { 8 | return [ 9 | { 10 | source: '/docs', 11 | destination: '/docs/getting-started', 12 | permanent: true, 13 | }, 14 | ]; 15 | }, 16 | }, 17 | webpack: (config, options) => { 18 | if (!options.isServer) { 19 | // Hack to make importing @babel/core not fail 20 | // TODO: Come up with a better alternative 21 | config.externals = config.externals || {}; 22 | config.externals.fs = 'null'; 23 | config.externals.typescript = 'null'; 24 | } 25 | return config; 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build-deps": "cd .. && yarn workspace nymus build && yarn workspace codemirror-mode-icu build ", 8 | "build": "yarn build-deps && next build", 9 | "start": "next start", 10 | "test": "echo \"no tests\"" 11 | }, 12 | "dependencies": { 13 | "@babel/code-frame": "^7.8.3", 14 | "@babel/plugin-transform-modules-commonjs": "^7.8.3", 15 | "@babel/plugin-transform-react-jsx": "^7.8.3", 16 | "@babel/plugin-transform-typescript": "^7.8.3", 17 | "@babel/standalone": "^7.8.4", 18 | "@material-ui/core": "^4.9.9", 19 | "@material-ui/icons": "^4.5.1", 20 | "@material-ui/lab": "^4.0.0-alpha.48", 21 | "@mdx-js/runtime": "^1.5.5", 22 | "@now/next": "^2.3.12", 23 | "clsx": "^1.0.4", 24 | "codemirror": "^5.50.2", 25 | "codemirror-mode-icu": "^0.1.0", 26 | "next": "^9.2.2-canary.15", 27 | "nymus": "^0.1.12", 28 | "prettier": "^2.0.2", 29 | "prism-react-renderer": "^1.0.2", 30 | "react": "16.13.1", 31 | "react-codemirror2": "^7.1.0", 32 | "react-dom": "16.13.1" 33 | }, 34 | "devDependencies": { 35 | "@types/babel__code-frame": "^7.0.1", 36 | "@types/babel__standalone": "^7.1.0", 37 | "@types/node": "^13.1.8", 38 | "@types/prettier": "^2.0.0", 39 | "@types/react": "^16.9.17", 40 | "@types/react-dom": "^16.9.4", 41 | "typescript": "^3.7.5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /docs/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import 'codemirror/lib/codemirror.css'; 2 | import 'codemirror/theme/duotone-light.css'; 3 | import 'codemirror/addon/lint/lint.css'; 4 | import '../src/app.css'; 5 | 6 | import React from 'react'; 7 | import { AppProps } from 'next/app'; 8 | import Head from 'next/head'; 9 | import { ThemeProvider } from '@material-ui/core/styles'; 10 | import CssBaseline from '@material-ui/core/CssBaseline'; 11 | import theme from '../src/theme'; 12 | 13 | export default function MyApp({ Component, pageProps }: AppProps) { 14 | React.useEffect(() => { 15 | // Remove the server-side injected CSS. 16 | const jssStyles = document.querySelector('#jss-server-side'); 17 | if (jssStyles) { 18 | jssStyles.parentElement!.removeChild(jssStyles); 19 | } 20 | }, []); 21 | 22 | return ( 23 | 24 | 25 | nymus 26 | 30 | 34 | 35 | 36 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /docs/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Document, { Head, Main, NextScript } from 'next/document'; 3 | import { ServerStyleSheets } from '@material-ui/core/styles'; 4 | import theme from '../src/theme'; 5 | 6 | export default class MyDocument extends Document { 7 | render() { 8 | return ( 9 | 10 | 11 | {/* PWA primary color */} 12 | 13 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | ); 24 | } 25 | } 26 | 27 | MyDocument.getInitialProps = async (ctx) => { 28 | // Render app and page and get the context of the page with collected side effects. 29 | const sheets = new ServerStyleSheets(); 30 | const originalRenderPage = ctx.renderPage; 31 | 32 | ctx.renderPage = () => 33 | originalRenderPage({ 34 | enhanceApp: (App) => (props) => sheets.collect(), 35 | }); 36 | 37 | const initialProps = await Document.getInitialProps(ctx); 38 | 39 | return { 40 | ...initialProps, 41 | // Styles fragment is rendered after the app and page rendering finish. 42 | styles: [ 43 | ...React.Children.toArray(initialProps.styles), 44 | sheets.getStyleElement(), 45 | ], 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /docs/pages/docs/[slug].tsx: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import { promises as fs } from 'fs'; 3 | import Link from '../../src/components/Link'; 4 | import Container from '@material-ui/core/Container'; 5 | import Grid from '@material-ui/core/Grid'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import { Typography, Box, CircularProgress } from '@material-ui/core'; 8 | import Mdx from '@mdx-js/runtime'; 9 | import ReactDomServer from 'react-dom/server'; 10 | import CodeBlock from '../../src/components/CodeBlock'; 11 | import Layout from '../../src/components/Layout'; 12 | import manifest from '../../markdown/manifest.json'; 13 | 14 | interface CodeProps { 15 | className: string; 16 | children: string; 17 | } 18 | 19 | async function renderMarkdown(markdown: string) { 20 | const components = { 21 | code: ({ className, children }: CodeProps) => ( 22 | 23 | {children} 24 | 25 | ), 26 | }; 27 | return ReactDomServer.renderToStaticMarkup( 28 | {markdown} 29 | ); 30 | } 31 | 32 | export async function getStaticPaths() { 33 | const { routes } = manifest; 34 | return { 35 | paths: routes.map((route) => ({ 36 | params: { slug: path.basename(route.path) }, 37 | })), 38 | fallback: false, 39 | }; 40 | } 41 | 42 | async function readMarkdownFile(slug: string): Promise { 43 | const fileContent = await fs.readFile( 44 | path.resolve('./markdown', slug + '.md'), 45 | { encoding: 'utf-8' } 46 | ); 47 | const markup = await renderMarkdown(fileContent); 48 | return markup; 49 | } 50 | 51 | interface DocumentationPageProps { 52 | content?: string; 53 | } 54 | 55 | export async function getStaticProps({ 56 | params, 57 | }: { 58 | params: { slug: string }; 59 | }): Promise<{ props: DocumentationPageProps }> { 60 | const content = await readMarkdownFile(params.slug); 61 | return { props: { content } }; 62 | } 63 | 64 | const useStyles = makeStyles((theme) => ({ 65 | loader: { 66 | display: 'flex', 67 | justifyContent: 'center', 68 | marginTop: theme.spacing(5), 69 | }, 70 | })); 71 | 72 | export default function DocumentationPage({ content }: DocumentationPageProps) { 73 | const { routes } = manifest; 74 | const classes = useStyles(); 75 | return ( 76 | 77 | 78 | 79 | 80 | {content ? ( 81 |
82 | ) : ( 83 |
84 | 85 |
86 | )} 87 | 88 | 89 | 90 | Docs 91 | {routes.map((route) => ( 92 | 99 | {route.title} 100 | 101 | ))} 102 | 103 | 104 | 105 | 106 | 107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /docs/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Grid, 4 | Container, 5 | Link, 6 | Typography, 7 | makeStyles, 8 | Box, 9 | } from '@material-ui/core'; 10 | import highlight from '../src/highlight'; 11 | import Layout from '../src/components/Layout'; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | title: { 15 | marginTop: theme.spacing(5), 16 | }, 17 | subtitle: { 18 | fontSize: 20, 19 | }, 20 | code: { 21 | '& .hljs': { 22 | padding: theme.spacing(3), 23 | }, 24 | }, 25 | })); 26 | 27 | interface HomePageProps { 28 | exampleInput: string; 29 | exampleOutput: string; 30 | } 31 | 32 | export async function getStaticProps(): Promise<{ 33 | props: HomePageProps; 34 | }> { 35 | const input = { 36 | Message: 'Hi {name}, your score is {score, number, percent}.', 37 | CurrentDate: "It's {now, time, short}.", 38 | Basket: 'I have {eggs, plural, one {one egg} other {# eggs}}.', 39 | Progress: 'Your score went {direction, select, up {up} other {down}}.', 40 | Navigate: 'Go to our about page.', 41 | }; 42 | const output = [ 43 | '', 44 | '', 45 | '', 46 | "", 47 | " {props.children}} />", 48 | ].join('\n'); 49 | return { 50 | props: { 51 | exampleInput: highlight(JSON.stringify(input, null, 2), 'json'), 52 | exampleOutput: highlight(output, 'jsx'), 53 | }, 54 | }; 55 | } 56 | 57 | function Home({ exampleInput, exampleOutput }: HomePageProps) { 58 | const classes = useStyles(); 59 | return ( 60 | 61 | 62 | 63 | nymus 64 | 65 | 66 | Transform{' '} 67 | 68 | ICU message format 69 | {' '} 70 | into React components 71 | 72 | 73 | 74 | 75 | 76 | Put in ICU formatted messages 77 | 78 |
82 | 83 | 84 | 85 | Get out React components 86 | 87 |
91 | 92 | 93 | 94 | 95 | 96 | ); 97 | } 98 | 99 | export default Home; 100 | -------------------------------------------------------------------------------- /docs/pages/playground.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import dynamic from 'next/dynamic'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import { CircularProgress } from '@material-ui/core'; 5 | import Layout from '../src/components/Layout'; 6 | 7 | const useStyles = makeStyles((theme) => ({ 8 | playground: { 9 | flex: 1, 10 | }, 11 | loading: { 12 | flex: 1, 13 | display: 'flex', 14 | height: '100%', 15 | justifyContent: 'center', 16 | alignItems: 'center', 17 | }, 18 | })); 19 | 20 | const Playground = dynamic(() => import('../src/components/Playground'), { 21 | ssr: false, 22 | loading: Loading, 23 | }); 24 | 25 | function Loading() { 26 | const classes = useStyles(); 27 | return ( 28 |
29 | 30 |
31 | ); 32 | } 33 | 34 | export default function PlaygroundPage() { 35 | const classes = useStyles(); 36 | return ( 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janpot/nymus/95b5e665d818a228716a57f288d3999bb656bbb5/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janpot/nymus/95b5e665d818a228716a57f288d3999bb656bbb5/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janpot/nymus/95b5e665d818a228716a57f288d3999bb656bbb5/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janpot/nymus/95b5e665d818a228716a57f288d3999bb656bbb5/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janpot/nymus/95b5e665d818a228716a57f288d3999bb656bbb5/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janpot/nymus/95b5e665d818a228716a57f288d3999bb656bbb5/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/src/app.css: -------------------------------------------------------------------------------- 1 | html, body, #__next { 2 | height: 100%; 3 | } 4 | 5 | .CodeMirror-lint-tooltip { 6 | z-index: 2000000; 7 | } 8 | -------------------------------------------------------------------------------- /docs/src/components/CodeBlock.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Highlight, { defaultProps, Language } from 'prism-react-renderer'; 3 | import theme from 'prism-react-renderer/themes/duotoneLight'; 4 | import clsx from 'clsx'; 5 | 6 | type CodeBlockProps = { 7 | className?: string; 8 | children: string; 9 | language: string; 10 | }; 11 | 12 | export default ({ 13 | className: outerClass, 14 | children, 15 | language, 16 | }: CodeBlockProps) => { 17 | return ( 18 | 24 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 25 |
29 |           {tokens.map((line, i) => (
30 |             
31 | {line.map((token, key) => ( 32 | 33 | ))} 34 |
35 | ))} 36 |
37 | )} 38 |
39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /docs/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import AppBar, { AppBarProps } from '@material-ui/core/AppBar'; 2 | import Toolbar from '@material-ui/core/Toolbar'; 3 | import Link, { NakedLink } from './Link'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import Button from '@material-ui/core/Button'; 6 | import GitHubIcon from '@material-ui/icons/GitHub'; 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | 9 | const useStyles = makeStyles({ 10 | title: { 11 | flexGrow: 1, 12 | }, 13 | }); 14 | 15 | interface HeaderProps {} 16 | 17 | export default function Header(props: HeaderProps & AppBarProps) { 18 | const classes = useStyles(); 19 | return ( 20 | 21 | 22 | 23 | nymus 24 | 25 | 33 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /docs/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from './Header'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | 4 | const useStyles = makeStyles({ 5 | root: { 6 | display: 'flex', 7 | flexDirection: 'column', 8 | height: '100%', 9 | }, 10 | title: { 11 | flexGrow: 1, 12 | }, 13 | content: { 14 | flex: 1, 15 | overflow: 'auto', 16 | }, 17 | }); 18 | 19 | export default function Layout({ children }: React.PropsWithChildren<{}>) { 20 | const classes = useStyles(); 21 | return ( 22 |
23 |
24 |
{children}
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /docs/src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable jsx-a11y/anchor-has-content */ 2 | import * as React from 'react'; 3 | import NextLink, { LinkProps as NextLinkProps } from 'next/link'; 4 | import MuiLink, { LinkProps as MuiLinkProps } from '@material-ui/core/Link'; 5 | 6 | type NextComposedProps = Omit< 7 | React.AnchorHTMLAttributes, 8 | 'href' 9 | > & 10 | NextLinkProps; 11 | 12 | const NextComposed = React.forwardRef( 13 | (props, ref) => { 14 | const { 15 | as, 16 | href, 17 | replace, 18 | scroll, 19 | passHref, 20 | shallow, 21 | prefetch, 22 | ...other 23 | } = props; 24 | 25 | return ( 26 | 35 | 36 | 37 | ); 38 | } 39 | ); 40 | 41 | interface LinkPropsBase { 42 | innerRef?: React.Ref; 43 | } 44 | 45 | export type LinkProps = LinkPropsBase & 46 | NextComposedProps & 47 | Omit; 48 | 49 | // A styled version of the Next.js Link component: 50 | // https://nextjs.org/docs/#with-link 51 | function Link(props: LinkProps) { 52 | const { href, className, innerRef, ...other } = props; 53 | 54 | return ( 55 | 62 | ); 63 | } 64 | 65 | export default React.forwardRef((props, ref) => ( 66 | 67 | )); 68 | 69 | export { NextComposed as NakedLink }; 70 | -------------------------------------------------------------------------------- /docs/src/components/Playground/CodeDialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import Dialog from '@material-ui/core/Dialog'; 4 | import IconButton from '@material-ui/core/IconButton'; 5 | import DialogTitle from '@material-ui/core/DialogTitle'; 6 | import CloseIcon from '@material-ui/icons/Close'; 7 | import CodeBlock from '../CodeBlock'; 8 | 9 | const useStyles = makeStyles((theme) => ({ 10 | container: { 11 | overflow: 'auto', 12 | }, 13 | content: { 14 | margin: 0, 15 | }, 16 | closeButton: { 17 | position: 'absolute', 18 | right: theme.spacing(1), 19 | top: theme.spacing(1), 20 | color: theme.palette.grey[500], 21 | }, 22 | })); 23 | 24 | interface CodeDialogProps { 25 | open: boolean; 26 | onClose: () => void; 27 | title: string; 28 | code: string; 29 | } 30 | 31 | export default function CodeDialog({ 32 | open, 33 | onClose, 34 | title, 35 | code, 36 | }: CodeDialogProps) { 37 | const classes = useStyles(); 38 | return ( 39 | 40 | 41 | Generated code 42 | 43 | 44 | 45 | 46 |
47 | 48 | {code} 49 | 50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /docs/src/components/Playground/Editor.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Controlled as ReactCodeMirror } from 'react-codemirror2'; 3 | import { CM_THEME } from '../../constants'; 4 | import { SourceLocation } from '@babel/code-frame'; 5 | 6 | import CodeMirror from 'codemirror'; 7 | import icuMode from 'codemirror-mode-icu'; 8 | import 'codemirror/mode/javascript/javascript'; 9 | import 'codemirror/mode/jsx/jsx'; 10 | import 'codemirror/addon/lint/lint'; 11 | 12 | export interface EditorError extends Error { 13 | location: SourceLocation; 14 | } 15 | 16 | interface EditorProps { 17 | mode?: 'icu' | 'jsx'; 18 | stretch?: boolean; 19 | className?: string; 20 | value: string; 21 | onChange: (value: string) => void; 22 | errors?: EditorError[]; 23 | } 24 | 25 | interface HelperOptions { 26 | errors: EditorError[]; 27 | } 28 | 29 | function linter(text: string, { errors = [] }: HelperOptions) { 30 | const editorHints = []; 31 | for (const error of errors) { 32 | const { location = { start: { line: 1 } } } = error; 33 | const { 34 | start, 35 | end = { ...start, column: start.column ? start.column + 1 : 1 }, 36 | } = location; 37 | const startColumn = start.column ? start.column - 1 : 0; 38 | const endColumn = end.column ? end.column - 1 : 0; 39 | editorHints.push({ 40 | message: error.message, 41 | severity: 'error', 42 | type: 'validation', 43 | from: CodeMirror.Pos(start.line - 1, startColumn), 44 | to: CodeMirror.Pos(end.line - 1, endColumn), 45 | }); 46 | } 47 | return editorHints; 48 | } 49 | 50 | CodeMirror.registerHelper('lint', 'icu', linter); 51 | CodeMirror.registerHelper('lint', 'javascript', linter); 52 | 53 | export default function Editor({ 54 | mode = 'icu', 55 | stretch, 56 | className, 57 | value, 58 | onChange, 59 | errors = [], 60 | }: EditorProps) { 61 | return ( 62 | { 81 | onChange(value); 82 | }} 83 | onChange={(editor, data, value) => {}} 84 | /> 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /docs/src/components/Playground/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Editor, { EditorError } from './Editor'; 3 | import { formatError, createModuleAst } from 'nymus'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import clsx from 'clsx'; 6 | import * as Babel from '@babel/standalone'; 7 | import TsPlugin from '@babel/plugin-transform-typescript'; 8 | import CommonjsPlugin from '@babel/plugin-transform-modules-commonjs'; 9 | import JsxPlugin from '@babel/plugin-transform-react-jsx'; 10 | import { format } from 'prettier/standalone'; 11 | import babelParser from 'prettier/parser-babel'; 12 | import Toolbar from '@material-ui/core/Toolbar'; 13 | import IconButton from '@material-ui/core/IconButton'; 14 | import Tooltip from '@material-ui/core/Tooltip'; 15 | import Typography from '@material-ui/core/Typography'; 16 | import Paper from '@material-ui/core/Paper'; 17 | import CodeIcon from '@material-ui/icons/Code'; 18 | import { SourceLocation } from '@babel/code-frame'; 19 | import CodeDialog from './CodeDialog'; 20 | 21 | const COMPONENT_NAME = 'Message'; 22 | 23 | async function createModule(input: { [key: string]: string }): Promise { 24 | const ast = await createModuleAst(input, { typescript: true, react: true }); 25 | const { code } = Babel.transformFromAst(ast, undefined, { 26 | generatorOpts: { concise: true }, 27 | }); 28 | return code || ''; 29 | } 30 | 31 | function prettify(code: string): string { 32 | return format(code, { 33 | parser: 'babel', 34 | plugins: [babelParser], 35 | }); 36 | } 37 | 38 | const useStyles = makeStyles((theme) => ({ 39 | root: { 40 | height: '100%', 41 | display: 'flex', 42 | flexDirection: 'row', 43 | padding: theme.spacing(1), 44 | }, 45 | column: { 46 | flex: 1, 47 | display: 'flex', 48 | flexDirection: 'column', 49 | }, 50 | rightPanel: { 51 | height: '50%', 52 | display: 'flex', 53 | flexDirection: 'column', 54 | }, 55 | pane: { 56 | margin: theme.spacing(1), 57 | }, 58 | paper: { 59 | display: 'flex', 60 | overflow: 'hidden', 61 | flexDirection: 'column', 62 | }, 63 | editor: { 64 | flex: 1, 65 | position: 'relative', 66 | '& .CodeMirror': { 67 | position: 'absolute', 68 | width: '100% !important', 69 | height: '100% !important', 70 | }, 71 | }, 72 | paneTitle: { 73 | flex: 1, 74 | }, 75 | renderResultContainer: { 76 | overflow: 'scroll', 77 | }, 78 | renderResult: { 79 | padding: theme.spacing(3), 80 | }, 81 | error: { 82 | height: '100%', 83 | color: theme.palette.error.dark, 84 | }, 85 | })); 86 | 87 | const SAMPLE = ` 88 | {gender, select, 89 | female {{ 90 | count, plural, 91 | =0 {Ela não tem nenhum Pokémon} 92 | one {Ela tem só um Pokémon} 93 | other {Ela tem # Pokémon} 94 | }} 95 | other {{ 96 | count, plural, 97 | =0 {Ele não tem nenhum Pokémon} 98 | one {Ele tem só um Pokémon} 99 | other {Ele tem # Pokémon} 100 | }} 101 | }`; 102 | 103 | function toEditorError( 104 | error: Error & { loc?: SourceLocation; location?: SourceLocation } 105 | ): EditorError { 106 | // babel standalone does weird things with locations 107 | const location = error.location 108 | ? error.location 109 | : error.loc 110 | ? error.loc.start 111 | ? error.loc 112 | : { start: error.loc } 113 | : { start: { line: 1 } }; 114 | return Object.assign(error, { location }); 115 | } 116 | 117 | function commentLines(lines: string) { 118 | return lines 119 | .split('\n') 120 | .map((line) => `// ${line}`) 121 | .join('\n'); 122 | } 123 | 124 | interface RendererErrorProps { 125 | error: Error; 126 | } 127 | 128 | function RendererError({ error }: RendererErrorProps) { 129 | const classes = useStyles(); 130 | return
{error.message}
; 131 | } 132 | 133 | interface UsePLaygroundProps { 134 | icuInput: string; 135 | consumerInput: string; 136 | } 137 | 138 | // simplest module loader in the world 139 | function createRequire( 140 | modules: { [key: string]: string }, 141 | moduleInstances: { [key: string]: any } = {} 142 | ) { 143 | const require = (moduleId: string) => { 144 | if (moduleInstances[moduleId]) { 145 | return moduleInstances[moduleId]; 146 | } 147 | if (!modules[moduleId]) { 148 | throw new Error(`Unknown module "${moduleId}"`); 149 | } 150 | const exports = {}; 151 | moduleInstances[moduleId] = exports; 152 | eval(`(exports, require) => { 153 | ${modules[moduleId]} 154 | }`)(exports, require); 155 | return exports; 156 | }; 157 | return require; 158 | } 159 | 160 | function usePlayground({ icuInput, consumerInput }: UsePLaygroundProps) { 161 | const [generatedModule, setGeneratedModule] = React.useState<{ 162 | code: string; 163 | compiled: string | null; 164 | errors: Error[]; 165 | }>({ 166 | code: '', 167 | compiled: null, 168 | errors: [], 169 | }); 170 | 171 | React.useEffect(() => { 172 | (async () => { 173 | try { 174 | const formatted = prettify( 175 | await createModule({ [COMPONENT_NAME]: icuInput }) 176 | ); 177 | const { code: compiled = null } = Babel.transform(formatted, { 178 | plugins: [CommonjsPlugin, TsPlugin], 179 | }); 180 | setGeneratedModule({ 181 | errors: [], 182 | code: formatted, 183 | compiled, 184 | }); 185 | } catch (error) { 186 | setGeneratedModule({ 187 | errors: [error], 188 | code: commentLines(formatError(icuInput, error)), 189 | compiled: null, 190 | }); 191 | } 192 | })(); 193 | }, [icuInput]); 194 | 195 | const consumerModule = React.useMemo<{ 196 | errors: EditorError[]; 197 | code: string; 198 | compiled: string | null; 199 | }>(() => { 200 | try { 201 | const { code } = Babel.transform(consumerInput, { 202 | plugins: [CommonjsPlugin, JsxPlugin], 203 | }); 204 | return { 205 | errors: [], 206 | code: consumerInput, 207 | compiled: code || null, 208 | }; 209 | } catch (error) { 210 | const { line, column } = error.loc; 211 | const location = { 212 | start: { line, column: line === 1 ? column - 7 : column }, 213 | }; 214 | return { 215 | errors: [Object.assign(error as Error, { location })], 216 | code: consumerInput, 217 | compiled: null, 218 | }; 219 | } 220 | }, [consumerInput]); 221 | 222 | const renderedResult = React.useMemo(() => { 223 | if (!generatedModule.compiled) { 224 | return null; 225 | } 226 | if (!consumerModule.compiled) { 227 | return null; 228 | } 229 | try { 230 | const require = createRequire( 231 | { 232 | messages: generatedModule.compiled, 233 | main: consumerModule.compiled, 234 | }, 235 | { 236 | react: React, 237 | } 238 | ); 239 | 240 | const { default: Result } = require('main'); 241 | 242 | return Result(); 243 | } catch (error) { 244 | return ; 245 | } 246 | }, [generatedModule, consumerModule]); 247 | 248 | return { 249 | generatedModule, 250 | consumerModule, 251 | renderedResult, 252 | }; 253 | } 254 | 255 | interface PlaygroundProps { 256 | className?: string; 257 | } 258 | 259 | export default function Playground({ className }: PlaygroundProps) { 260 | const classes = useStyles(); 261 | 262 | const [generatedCodeOpen, setGeneratedCodeOpen] = React.useState(false); 263 | const [icuInput, setIcuInput] = React.useState(SAMPLE.trim()); 264 | const [consumerInput, setConsumerInput] = React.useState( 265 | `import * as React from 'react'; 266 | import { ${COMPONENT_NAME} } from 'messages'; 267 | 268 | export default function () { 269 | return <${COMPONENT_NAME} gender="male" count={5} /> 270 | }` 271 | ); 272 | 273 | const { generatedModule, consumerModule, renderedResult } = usePlayground({ 274 | icuInput, 275 | consumerInput, 276 | }); 277 | 278 | return ( 279 |
280 | 281 | 282 | ICU message 283 | 284 | setGeneratedCodeOpen(true)}> 285 | 286 | 287 | 288 | 289 | 295 | setGeneratedCodeOpen(false)} 297 | open={generatedCodeOpen} 298 | title="Generated code" 299 | code={generatedModule.code} 300 | /> 301 | 302 |
303 | 306 | 307 | Consumer 308 | 309 | 316 | 317 |
318 | 319 | 320 | Rendered result 321 | 322 | 323 |
324 |
{renderedResult}
325 |
326 |
327 |
328 |
329 | ); 330 | } 331 | -------------------------------------------------------------------------------- /docs/src/components/nav.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | 4 | const links = [ 5 | { href: 'https://zeit.co/now', label: 'ZEIT' }, 6 | { href: 'https://github.com/zeit/next.js', label: 'GitHub' }, 7 | ].map((link) => ({ 8 | key: `nav-link-${link.href}-${link.label}`, 9 | ...link, 10 | })); 11 | 12 | const Nav = () => ( 13 |
54 | ); 55 | 56 | export default Nav; 57 | -------------------------------------------------------------------------------- /docs/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CM_THEME = 'duotone-light'; 2 | -------------------------------------------------------------------------------- /docs/src/highlight.tsx: -------------------------------------------------------------------------------- 1 | import ReactDomServer from 'react-dom/server'; 2 | import CodeBlock from './components/CodeBlock'; 3 | 4 | export default function highlightCode(code: string, language: string): string { 5 | return ReactDomServer.renderToStaticMarkup( 6 | {code} 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /docs/src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | 3 | // Create a theme instance. 4 | const theme = createMuiTheme({ 5 | palette: { 6 | type: 'light', 7 | }, 8 | }); 9 | 10 | export default theme; 11 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "skipLibCheck": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve" 14 | }, 15 | "exclude": ["node_modules"], 16 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 17 | } 18 | -------------------------------------------------------------------------------- /examples/next.js/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | .env* 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /examples/next.js/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | ## Summary 4 | 5 | This example will run three instances of the same next.js app on 3 different base paths. It will alias a different locale folder for each of the three instances. It will also provide localized messages as React components, compiled by `nymus`. 6 | 7 | The next app is made configurable through environment variables, which are read in `next.config.js`. Based on a `LOCALE` variable it alters: 8 | 9 | 1. the build directory, so the different instances don't overwrite each other 10 | 2. the `basePath` (experimental feature) 11 | 3. a webpack alias to `@locale` that points to a locale folder under `./locales/` 12 | 13 | It also adds `nymus/webpack` to load the localized string. 14 | 15 | ## Develop 16 | 17 | Now the app can be started for a single locale by running 18 | 19 | ``` 20 | yarn dev 21 | ``` 22 | 23 | then visit `http://localhost:3000/en` 24 | 25 | ## Deploy to `now` 26 | 27 | The app can be deployed to `now` with the command 28 | 29 | ``` 30 | yarn deploy 31 | ``` 32 | -------------------------------------------------------------------------------- /examples/next.js/components/nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LocaleNl, LocaleEn, LocaleFr } from '@locale/strings.json'; 3 | 4 | function LocalePicker() { 5 | const locales = process.env.LOCALES.split(','); 6 | return ( 7 | <> 8 | 21 | {locales 22 | .filter((locale) => locale !== process.env.LOCALE) 23 | .map((locale, i) => { 24 | return ( 25 | <> 26 | {i > 0 ? | : null} 27 | 28 | {{ 29 | en: , 30 | nl: , 31 | fr: , 32 | }[locale] || '??'} 33 | 34 | 35 | ); 36 | })} 37 | 38 | ); 39 | } 40 | 41 | const links = [ 42 | { href: 'https://zeit.co/now', label: 'ZEIT' }, 43 | { href: 'https://github.com/zeit/next.js', label: 'GitHub' }, 44 | ].map((link) => ({ 45 | ...link, 46 | key: `nav-link-${link.href}-${link.label}`, 47 | })); 48 | 49 | const Nav = () => ( 50 | 89 | ); 90 | 91 | export default Nav; 92 | -------------------------------------------------------------------------------- /examples/next.js/locales/en/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "HomeTitle": "Welcome to Next.js!", 3 | "HomeDescription": "To get started, edit pages/index.js and save to reload.", 4 | "HomeLinksDocs": "DocumentationLearn more about Next.js in the documentation.", 5 | "HomeLinksLearn": "Next.js LearnLearn about Next.js by following an interactive tutorial!", 6 | "HomeLinksExamples": "ExamplesFind other example boilerplates on the Next.js GitHub.", 7 | "HomeLabel": "Home", 8 | "LocaleEn": "English", 9 | "LocaleNl": "Nederlands", 10 | "LocaleFr": "Français" 11 | } 12 | -------------------------------------------------------------------------------- /examples/next.js/locales/fr/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "HomeTitle": "Bienvenue chez Next.js!", 3 | "HomeDescription": "Pour commencer, modifiez pages/index.js et enregistrez pour recharger.", 4 | "HomeLinksDocs": "DocumentationEn savoir plus sur Next.js dans la documentation.", 5 | "HomeLinksLearn": "Apprends Next.jsDécouvrez Next.js en suivant un tutoriel interactif!", 6 | "HomeLinksExamples": "ExemplesTrouvez d'autres exemples de passe-partout sur le Next.js GitHub.", 7 | "HomeLabel": "Home", 8 | "LocaleEn": "English", 9 | "LocaleNl": "Nederlands", 10 | "LocaleFr": "Français" 11 | } 12 | -------------------------------------------------------------------------------- /examples/next.js/locales/nl/strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "HomeTitle": "Welkom bij Next.js!", 3 | "HomeDescription": "Om te beginnen, pas pages/index.js aan en sla op om the herladen.", 4 | "HomeLinksDocs": "DocumentatieKom meer te weten over Next.js in de documentatie.", 5 | "HomeLinksLearn": "Next.js LerenLeer meer over Next.js Met de interactieve tutorial!", 6 | "HomeLinksExamples": "VoorbeeldenVind boilerplate voorbeelden in Next.js GitHub repository.", 7 | "HomeLabel": "Start", 8 | "LocaleEn": "English", 9 | "LocaleNl": "Nederlands", 10 | "LocaleFr": "Français" 11 | } 12 | -------------------------------------------------------------------------------- /examples/next.js/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | const locale = process.env.LOCALE || 'en'; 5 | 6 | const basePath = process.env.BASE_PATH || `/${locale}`; 7 | const localesFolder = path.resolve(__dirname, './locales/'); 8 | const locales = fs.readdirSync(localesFolder); 9 | 10 | module.exports = { 11 | experimental: { 12 | basePath, 13 | rewrites: async () => { 14 | const localeRewrites = []; 15 | for (const locale of locales) { 16 | const localeUrl = process.env[`LOCALE_URL_${locale}`]; 17 | if (localeUrl) { 18 | const destination = new URL(`/${locale}/:path*`, localeUrl); 19 | localeRewrites.push({ 20 | source: `/${locale}/:path*`, 21 | destination: destination.toString(), 22 | }); 23 | } 24 | } 25 | return localeRewrites; 26 | }, 27 | }, 28 | assetPrefix: basePath, 29 | env: { 30 | LOCALE: locale, 31 | LOCALES: locales.join(','), 32 | }, 33 | webpack: (config, options) => { 34 | config.resolve.alias['@locale'] = path.resolve( 35 | __dirname, 36 | `./locales/${locale}` 37 | ); 38 | 39 | config.module.rules.push({ 40 | test: /\.json$/, 41 | include: [localesFolder], 42 | type: 'javascript/auto', 43 | use: [ 44 | options.defaultLoaders.babel, 45 | { 46 | loader: 'nymus/webpack', 47 | options: { locale, declarations: false }, 48 | }, 49 | ], 50 | }); 51 | 52 | return config; 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /examples/next.js/now.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /examples/next.js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build", 8 | "start": "next start", 9 | "deploy": "node scripts/deploy" 10 | }, 11 | "dependencies": { 12 | "concurrently": "^5.0.2", 13 | "execa": "^4.0.0", 14 | "micro-proxy": "^1.1.0", 15 | "next": "^9.2.2-canary.12", 16 | "now": "^17.0.2", 17 | "react": "16.13.1", 18 | "react-dom": "16.13.1" 19 | }, 20 | "devDependencies": { 21 | "nymus": "^0.1.16" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/next.js/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Head from 'next/head'; 3 | import Nav from '../components/nav'; 4 | import { 5 | HomeTitle, 6 | HomeDescription, 7 | HomeLinksDocs, 8 | HomeLinksLearn, 9 | HomeLinksExamples, 10 | HomeLabel, 11 | } from '@locale/strings.json'; 12 | 13 | function LinkTitle({ children }) { 14 | return ( 15 |

16 | 23 | {children} → 24 |

25 | ); 26 | } 27 | 28 | function LinkSubTitle({ children }) { 29 | return ( 30 |

31 | 39 | {children} 40 |

41 | ); 42 | } 43 | 44 | const Home = () => ( 45 |
46 | 47 | {HomeLabel()} 48 | 49 | 50 | 51 |
113 | ); 114 | 115 | export default Home; 116 | -------------------------------------------------------------------------------- /examples/next.js/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Janpot/nymus/95b5e665d818a228716a57f288d3999bb656bbb5/examples/next.js/public/favicon.ico -------------------------------------------------------------------------------- /examples/next.js/scripts/build.js: -------------------------------------------------------------------------------- 1 | const concurrently = require('concurrently'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { promisify } = require('util'); 5 | const fsWriteFile = promisify(fs.writeFile); 6 | 7 | const PORT = 3000; 8 | const LOCALES = ['en', 'nl', 'fr']; 9 | 10 | function getPort(i) { 11 | return PORT + 1 + i; 12 | } 13 | 14 | async function buildProxyRules() { 15 | const rules = LOCALES.map((locale, i) => ({ 16 | pathname: `/${locale}`, 17 | dest: `http://localhost:${getPort(i)}`, 18 | })); 19 | 20 | await fsWriteFile( 21 | path.resolve(__dirname, '../.next/rules.json'), 22 | JSON.stringify({ rules }, null, 2), 23 | { encoding: 'utf-8' } 24 | ); 25 | } 26 | 27 | async function buildNext() { 28 | const commands = LOCALES.map((locale, i) => ({ 29 | name: `build:${locale}`, 30 | command: `LOCALE=${locale} LOCALES=${LOCALES.join(',')} next build`, 31 | })); 32 | await concurrently(commands); 33 | } 34 | 35 | async function main() { 36 | await Promise.all([buildProxyRules(), buildNext()]); 37 | } 38 | 39 | process.on('unhandledRejection', (err) => { 40 | throw err; 41 | }); 42 | main(); 43 | -------------------------------------------------------------------------------- /examples/next.js/scripts/deploy.js: -------------------------------------------------------------------------------- 1 | const execa = require('execa'); 2 | const { promises: fs } = require('fs'); 3 | const path = require('path'); 4 | 5 | const DEFAULT_LOCALE = 'en'; 6 | 7 | const projectRoot = path.resolve(__dirname, '..'); 8 | const localesFolder = path.resolve(projectRoot, './locales/'); 9 | 10 | const prod = process.argv.includes('--prod'); 11 | 12 | if (prod) { 13 | console.log('Deploying to production'); 14 | } else { 15 | console.log('Run with --prod to deploy to production'); 16 | } 17 | 18 | async function deploy({ locale, prod = false, urls = {} }) { 19 | const buildEnvParams = []; 20 | for (const [urlLocale, localeUrl] of Object.entries(urls)) { 21 | buildEnvParams.push('--build-env', `LOCALE_URL_${urlLocale}=${localeUrl}`); 22 | } 23 | console.log(`Deploying "${locale}"`); 24 | const { stdout: url } = await execa( 25 | 'now', 26 | [ 27 | 'deploy', 28 | '--no-clipboard', 29 | ...(prod ? ['--prod'] : []), 30 | ...buildEnvParams, 31 | '--build-env', 32 | `LOCALE=${locale}`, 33 | '--meta', 34 | `locale=${locale}`, 35 | projectRoot, 36 | ], 37 | { 38 | cwd: __dirname, 39 | preferLocal: true, 40 | } 41 | ); 42 | console.log(` => ${url}`); 43 | return { url }; 44 | } 45 | 46 | async function main() { 47 | try { 48 | await fs.stat(path.resolve(projectRoot, '.now')); 49 | } catch (err) { 50 | // deployment not set up 51 | // set it up first 52 | await execa('now', ['deploy', '--no-clipboard', deploymentPath], { 53 | stdio: 'inherit', 54 | cwd: __dirname, 55 | preferLocal: true, 56 | }); 57 | } 58 | 59 | const locales = await fs.readdir(localesFolder); 60 | 61 | const urls = {}; 62 | for (const locale of locales) { 63 | const { url } = await deploy({ locale, prod: false }); 64 | urls[locale] = url; 65 | } 66 | 67 | await deploy({ 68 | locale: DEFAULT_LOCALE, 69 | prod, 70 | urls, 71 | }); 72 | } 73 | 74 | process.on('unhandledRejection', (err) => { 75 | throw err; 76 | }); 77 | main(); 78 | -------------------------------------------------------------------------------- /examples/next.js/scripts/start.js: -------------------------------------------------------------------------------- 1 | const concurrently = require('concurrently'); 2 | 3 | const LOCALES = ['en', 'nl', 'fr']; 4 | const PORT = 3000; 5 | 6 | function getPort(i) { 7 | return PORT + 1 + i; 8 | } 9 | 10 | async function main() { 11 | const commands = LOCALES.map((locale, i) => { 12 | const port = getPort(i); 13 | const locales = LOCALES.join(','); 14 | return { 15 | command: `LOCALE=${locale} LOCALES=${locales} next start -p ${port}`, 16 | name: `start:${locale}`, 17 | }; 18 | }); 19 | 20 | await concurrently( 21 | [ 22 | { 23 | command: `micro-proxy -r ./.next/rules.json -p ${PORT}`, 24 | name: 'proxy', 25 | }, 26 | ...commands, 27 | ], 28 | { 29 | killOthers: ['success', 'failure'], 30 | } 31 | ); 32 | } 33 | 34 | process.on('unhandledRejection', (err) => { 35 | throw err; 36 | }); 37 | main(); 38 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "independent", 4 | "npmClient": "yarn" 5 | } 6 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nymus", 3 | "builds": [ 4 | { 5 | "src": "docs/next.config.js", 6 | "use": "@now/next" 7 | } 8 | ], 9 | "rewrites": [ 10 | { 11 | "source": "/(.*)", 12 | "destination": "docs/$1" 13 | } 14 | ], 15 | "redirects": [ 16 | { 17 | "source": "/docs", 18 | "destination": "/docs/getting-started", 19 | "statusCode": 308 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nymus-root", 3 | "private": true, 4 | "workspaces": [ 5 | "packages/*", 6 | "docs" 7 | ], 8 | "scripts": { 9 | "build": "yarn workspaces run build", 10 | "test": "yarn workspaces run test", 11 | "fix": "$npm_execpath prettier --write", 12 | "prettier": "prettier --check \"**/*.{js,ts,jsx,tsx,json,yml,md}\"", 13 | "version": "lerna version", 14 | "publish": "lerna publish from-package --yes" 15 | }, 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@now/next": "^2.3.12", 19 | "lerna": "^3.20.2", 20 | "prettier": "^2.0.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/codemirror-mode-icu/.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/preset-typescript'], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/codemirror-mode-icu/.gitignore: -------------------------------------------------------------------------------- 1 | mode.js 2 | -------------------------------------------------------------------------------- /packages/codemirror-mode-icu/example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 25 | 26 | 27 |
28 | 56 |
57 | 58 | 59 | 60 | 61 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /packages/codemirror-mode-icu/mode.css: -------------------------------------------------------------------------------- 1 | .cm-visible-space::before, 2 | .cm-visible-space::before { 3 | content: '·'; 4 | } 5 | 6 | .cm-visible-tab::before, 7 | .cm-visible-tab::before { 8 | content: '↦'; 9 | } 10 | 11 | .cm-visible-space, 12 | .cm-visible-space, 13 | .cm-visible-tab , 14 | .cm-visible-tab { 15 | position: relative; 16 | } 17 | 18 | .cm-visible-space::before, 19 | .cm-visible-space::before, 20 | .cm-visible-tab::before , 21 | .cm-visible-tab::before { 22 | position: absolute; 23 | left: 0; 24 | right: 0; 25 | text-align: center; 26 | line-height: 1em; 27 | opacity: 0.3; 28 | } 29 | 30 | .cm-visible-eol:last-child::after, 31 | .cm-visible-eol:last-child::after { 32 | position: absolute; 33 | opacity: 0.3; 34 | content: '¬' 35 | } 36 | 37 | -------------------------------------------------------------------------------- /packages/codemirror-mode-icu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "codemirror-mode-icu", 3 | "version": "0.1.4", 4 | "description": "codemirror language mode for ICU message format", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "prepack": "npm run build", 9 | "build": "rm -rf ./dist && tsc", 10 | "test": "jest" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@babel/core": "^7.8.3", 17 | "@babel/preset-env": "^7.8.3", 18 | "@babel/preset-typescript": "^7.8.3", 19 | "@types/codemirror": "0.0.90", 20 | "@types/jest": "^25.2.1", 21 | "babel-jest": "^25.1.0", 22 | "codemirror": "^5.50.2", 23 | "jest": "^25.1.0", 24 | "typescript": "^3.7.4" 25 | }, 26 | "gitHead": "62422a94caa37e1780c069950fab8ac7d39df3f9" 27 | } 28 | -------------------------------------------------------------------------------- /packages/codemirror-mode-icu/src/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { Mode, defineMode, getMode, StringStream } from 'codemirror'; 4 | import modeFactory from './index'; 5 | 6 | defineMode('icu', modeFactory); 7 | const mode = getMode({}, { name: 'icu', showInvisible: false }); 8 | 9 | type TokenResultArray = (string | null)[][]; 10 | 11 | function pushToken( 12 | resultTokens: TokenResultArray, 13 | token: string | null, 14 | current: string 15 | ) { 16 | if (resultTokens.length <= 0) { 17 | resultTokens.push([current, token]); 18 | } else { 19 | const lastToken = resultTokens[resultTokens.length - 1]; 20 | if (lastToken[1] === token) { 21 | lastToken[0] = lastToken[0] + current; 22 | } else { 23 | resultTokens.push([current, token]); 24 | } 25 | } 26 | } 27 | 28 | function testMode(mode: Mode, str: string) { 29 | const state = mode.startState!(); 30 | const lines = str.split('/n'); 31 | const resultTokens: TokenResultArray = []; 32 | for (let lineNr = 0; lineNr < lines.length; lineNr++) { 33 | const stream = new StringStream(lines[lineNr]); 34 | while (!stream.eol()) { 35 | const token = mode.token!(stream, state); 36 | pushToken(resultTokens, token, stream.current()); 37 | stream.start = stream.pos; 38 | } 39 | } 40 | return resultTokens; 41 | } 42 | 43 | function defineTest( 44 | name: string, 45 | mode: Mode, 46 | input: (string[] | string)[], 47 | itFn = it 48 | ) { 49 | itFn(name, () => { 50 | const langStr = input 51 | .map((token) => { 52 | return typeof token === 'string' ? token : token[0]; 53 | }) 54 | .join(''); 55 | const expectedTokens = input.map((token) => { 56 | return typeof token === 'string' ? [token, null] : token; 57 | }); 58 | const gotTokens = testMode(mode, langStr); 59 | expect(gotTokens).toEqual(expectedTokens); 60 | }); 61 | } 62 | 63 | defineTest('simple string', mode, [['abc', 'string']]); 64 | 65 | defineTest('simple argument', mode, [ 66 | ['abc', 'string'], 67 | ['{', 'bracket'], 68 | ['def', 'def'], 69 | ['}', 'bracket'], 70 | ['ghi', 'string'], 71 | ]); 72 | 73 | defineTest('function argument', mode, [ 74 | ['{', 'bracket'], 75 | ['def', 'def'], 76 | ',', 77 | ['select', 'keyword'], 78 | ['}', 'bracket'], 79 | ]); 80 | 81 | defineTest('function with whitespace', mode, [ 82 | ['{', 'bracket'], 83 | ' ', 84 | ['xyz', 'def'], 85 | ' , ', 86 | ['select', 'keyword'], 87 | ' ', 88 | ['}', 'bracket'], 89 | ]); 90 | 91 | defineTest('function with format', mode, [ 92 | ['{', 'bracket'], 93 | ['def', 'def'], 94 | ',', 95 | ['date', 'keyword'], 96 | ',', 97 | ['short', 'variable'], 98 | ['}', 'bracket'], 99 | ]); 100 | 101 | defineTest('no placeholder detection in top level string', mode, [ 102 | ['ab#c', 'string'], 103 | ]); 104 | 105 | defineTest('ignore top level closing brace', mode, [['ab}c', 'string']]); 106 | 107 | describe('escaped sequences', () => { 108 | function apostropheTests(mode: Mode) { 109 | defineTest('accepts "Don\'\'t"', mode, [ 110 | ['Don', 'string'], 111 | ["''", 'string-2'], 112 | ['t', 'string'], 113 | ]); 114 | 115 | defineTest("starts quoting after '{", mode, [ 116 | ['I see ', 'string'], 117 | ["'{many}'", 'string-2'], 118 | ]); 119 | 120 | defineTest("starts quoting after '{", mode, [ 121 | ['I ay ', 'string'], 122 | ["'{''wow''}'", 'string-2'], 123 | ]); 124 | } 125 | 126 | const doubleOptionalMode = getMode( 127 | {}, 128 | { name: 'icu', apostropheMode: 'DOUBLE_OPTIONAL', showInvisible: false } 129 | ); 130 | const doubleRequiredMode = getMode( 131 | {}, 132 | { name: 'icu', apostropheMode: 'DOUBLE_REQUIRED', showInvisible: false } 133 | ); 134 | 135 | describe('apostropheMode:DOUBLE_OPTIONAL', () => { 136 | apostropheTests(doubleOptionalMode); 137 | 138 | defineTest('accepts "Don\'t" as a string', mode, [["Don't", 'string']]); 139 | 140 | defineTest('last character is quote', mode, [["a'", 'string']]); 141 | }); 142 | 143 | describe('apostropheMode:DOUBLE_REQUIRED', () => { 144 | apostropheTests(doubleRequiredMode); 145 | 146 | defineTest('uses single quotes for escape', doubleRequiredMode, [ 147 | ['ab', 'string'], 148 | ["'{'", 'string-2'], 149 | ['c', 'string'], 150 | ]); 151 | 152 | defineTest('can escape in escaped sequence', doubleRequiredMode, [ 153 | ['ab', 'string'], 154 | ["'c''d'", 'string-2'], 155 | ['e', 'string'], 156 | ]); 157 | 158 | defineTest('can escape a quote', doubleRequiredMode, [ 159 | ['ab', 'string'], 160 | ["''", 'string-2'], 161 | ['c', 'string'], 162 | ]); 163 | 164 | defineTest('can on the next line', doubleRequiredMode, [ 165 | ['ab', 'string'], 166 | ["'c\n'", 'string-2'], 167 | ['d', 'string'], 168 | ]); 169 | 170 | defineTest('can on the next line and text', doubleRequiredMode, [ 171 | ['ab', 'string'], 172 | ["'\nc'", 'string-2'], 173 | ['d', 'string'], 174 | ]); 175 | 176 | defineTest('Starts escaping "Don\'t"', doubleRequiredMode, [ 177 | ['Don', 'string'], 178 | ["'t", 'string-2'], 179 | ]); 180 | 181 | defineTest('last character is quote', doubleRequiredMode, [ 182 | ['a', 'string'], 183 | ["'", 'string-2'], 184 | ]); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /packages/codemirror-mode-icu/src/index.ts: -------------------------------------------------------------------------------- 1 | import { ModeFactory, StringStream } from 'codemirror'; 2 | 3 | type Frame = 4 | | { 5 | type: 'argument'; 6 | indentation: number; 7 | formatType?: string; 8 | argPos: number; 9 | } 10 | | { 11 | type: 'escaped'; 12 | } 13 | | { 14 | type: 'text'; 15 | }; 16 | 17 | interface ModeState { 18 | stack: Frame[]; 19 | } 20 | 21 | const mode: ModeFactory = ( 22 | { indentUnit = 2 } = {}, 23 | { apostropheMode = 'DOUBLE_OPTIONAL' } = {} 24 | ) => { 25 | function peek(stream: StringStream, offset = 0) { 26 | return stream.string.charAt(stream.pos + offset) || undefined; 27 | } 28 | 29 | function eatEscapedStringStart(stream: StringStream, inPlural: boolean) { 30 | const nextChar = stream.peek(); 31 | if (nextChar === "'") { 32 | if (apostropheMode === 'DOUBLE_OPTIONAL') { 33 | const nextAfterNextChar = peek(stream, 1); 34 | if ( 35 | nextAfterNextChar === "'" || 36 | nextAfterNextChar === '{' || 37 | (inPlural && nextAfterNextChar === '#') 38 | ) { 39 | stream.next(); 40 | return true; 41 | } 42 | } else { 43 | stream.next(); 44 | return true; 45 | } 46 | } 47 | return false; 48 | } 49 | 50 | function eatEscapedStringEnd(stream: StringStream) { 51 | const nextChar = peek(stream, 0); 52 | if (nextChar === "'") { 53 | const nextAfterNextChar = peek(stream, 1); 54 | if (!nextAfterNextChar || nextAfterNextChar !== "'") { 55 | stream.next(); 56 | return true; 57 | } 58 | } 59 | return false; 60 | } 61 | 62 | function pop(stack: Frame[]) { 63 | if (stack.length > 1) { 64 | stack.pop(); 65 | return true; 66 | } 67 | return false; 68 | } 69 | 70 | return { 71 | startState() { 72 | return { 73 | stack: [ 74 | { 75 | type: 'text', 76 | }, 77 | ], 78 | }; 79 | }, 80 | 81 | copyState(state) { 82 | return { 83 | stack: state.stack.map((frame) => Object.assign({}, frame)), 84 | }; 85 | }, 86 | 87 | token(stream, state) { 88 | const current = state.stack[state.stack.length - 1]; 89 | const isInsidePlural = !!state.stack.find( 90 | (frame) => 91 | frame.type === 'argument' && 92 | frame.formatType && 93 | ['selectordinal', 'plural'].includes(frame.formatType) 94 | ); 95 | 96 | if (current.type === 'escaped') { 97 | if (eatEscapedStringEnd(stream)) { 98 | pop(state.stack); 99 | return 'string-2'; 100 | } 101 | 102 | stream.match("''") || stream.next(); 103 | return 'string-2'; 104 | } 105 | 106 | if (current.type === 'text') { 107 | if (eatEscapedStringStart(stream, isInsidePlural)) { 108 | state.stack.push({ type: 'escaped' }); 109 | return 'string-2'; 110 | } 111 | 112 | if (isInsidePlural && stream.eat('#')) { 113 | return 'keyword'; 114 | } 115 | 116 | if (stream.eat('{')) { 117 | state.stack.push({ 118 | type: 'argument', 119 | indentation: stream.indentation() + indentUnit, 120 | argPos: 0, 121 | }); 122 | return 'bracket'; 123 | } 124 | 125 | if (stream.peek() === '}') { 126 | if (pop(state.stack)) { 127 | stream.next(); 128 | return 'bracket'; 129 | } 130 | } 131 | 132 | stream.next(); 133 | return 'string'; 134 | } 135 | 136 | if (current.type === 'argument') { 137 | const inId = current.argPos === 0; 138 | const inFn = current.argPos === 1; 139 | const inFormat = current.argPos === 2; 140 | if (stream.match(/\s*,\s*/)) { 141 | current.argPos += 1; 142 | return null; 143 | } 144 | if (inId && stream.eatWhile(/[a-zA-Z0-9_]/)) { 145 | return 'def'; 146 | } 147 | if ( 148 | inFn && 149 | stream.match(/(selectordinal|plural|select|number|date|time)\b/) 150 | ) { 151 | current.formatType = stream.current(); 152 | return 'keyword'; 153 | } 154 | if (inFormat && stream.match(/offset\b/)) { 155 | return 'keyword'; 156 | } 157 | if (inFormat && stream.eat('=')) { 158 | return 'operator'; 159 | } 160 | if ( 161 | inFormat && 162 | current.formatType && 163 | ['selectordinal', 'plural'].includes(current.formatType) && 164 | stream.match(/zero|one|two|few|many/) 165 | ) { 166 | return 'keyword'; 167 | } 168 | if (inFormat && stream.match('other')) { 169 | return 'keyword'; 170 | } 171 | if (inFormat && stream.match(/[0-9]+\b/)) { 172 | return 'number'; 173 | } 174 | if (inFormat && stream.eatWhile(/[a-zA-Z0-9_]/)) { 175 | return 'variable'; 176 | } 177 | if (inFormat && stream.eat('{')) { 178 | state.stack.push({ type: 'text' }); 179 | return 'bracket'; 180 | } 181 | if (stream.eat('}')) { 182 | pop(state.stack); 183 | return 'bracket'; 184 | } 185 | } 186 | 187 | if (!stream.eatSpace()) { 188 | stream.next(); 189 | } 190 | 191 | return null; 192 | }, 193 | 194 | blankLine(state) { 195 | const current = state.stack[state.stack.length - 1]; 196 | if (current.type === 'text') { 197 | return 'cm-string'; 198 | } 199 | return undefined; 200 | }, 201 | 202 | indent(state, textAfter) { 203 | var current = state.stack[state.stack.length - 1]; 204 | if (!current || current.type === 'text' || current.type === 'escaped') { 205 | return 0; 206 | } 207 | if (textAfter[0] === '}') { 208 | return current.indentation - indentUnit; 209 | } 210 | return current.indentation; 211 | }, 212 | }; 213 | }; 214 | 215 | export default mode; 216 | -------------------------------------------------------------------------------- /packages/codemirror-mode-icu/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["**/*.test.ts"], 4 | "extends": "../../tsconfig.json", 5 | "compilerOptions": { 6 | "target": "es5", 7 | "lib": ["es2015", "DOM"], 8 | "module": "commonjs", 9 | "outDir": "dist", 10 | "rootDir": "src", 11 | "declaration": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/nymus/.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/nymus/README.md: -------------------------------------------------------------------------------- 1 |

🦉 nymus

2 |

3 | 4 | Version 5 | 6 | 7 | License: MIT 8 | 9 | 10 | nymus test status 11 | 12 |

13 | 14 | > Transform [ICU messages](http://userguide.icu-project.org/formatparse/messages) into React components. 15 | 16 | ## Usage 17 | 18 | ### Example 19 | 20 | ```sh 21 | npx nymus ./messages.json 22 | ``` 23 | 24 | given a `./messages.json` file: 25 | 26 | ```json 27 | { 28 | "Welcome": "It's {name}, {gender, select, male {his} female {her} other {their}} birthday is {birthday, date, long}" 29 | } 30 | ``` 31 | 32 | `nymus` will generate a module containing React components that can be readily imported in your project as follows: 33 | 34 | ```js 35 | import * as React from 'react'; 36 | import { Welcome } from './messages'; 37 | 38 | export function HomePage() { 39 | return ; 40 | } 41 | ``` 42 | 43 | ## Documentation 44 | 45 | - [playground](https://nymus.now.sh/playground) 46 | - [documentation](https://nymus.now.sh/docs) 47 | 48 | ## Author 49 | 50 | 👤 **Jan Potoms** 51 | 52 | - Github: [@janpot](https://github.com/janpot) 53 | -------------------------------------------------------------------------------- /packages/nymus/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./dist/cli.js'); 3 | -------------------------------------------------------------------------------- /packages/nymus/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | }; 4 | -------------------------------------------------------------------------------- /packages/nymus/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nymus", 3 | "version": "0.2.0", 4 | "description": "Transform ICU messages into React components.", 5 | "keywords": [ 6 | "i18n", 7 | "internationalization", 8 | "localization", 9 | "l18n", 10 | "ICU", 11 | "messageformat", 12 | "translation" 13 | ], 14 | "main": "dist/index.js", 15 | "types": "dist/index.d.ts", 16 | "bin": { 17 | "nymus": "./cli.js" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/Janpot/nymus.git" 22 | }, 23 | "homepage": "https://nymus.now.sh/", 24 | "scripts": { 25 | "prepack": "npm run build", 26 | "build": "rm -rf ./dist && tsc", 27 | "test": "jest" 28 | }, 29 | "files": [ 30 | "dist", 31 | "cli.js", 32 | "webpack.js" 33 | ], 34 | "author": "Jan Potoms", 35 | "license": "MIT", 36 | "dependencies": { 37 | "@babel/code-frame": "^7.8.3", 38 | "@babel/core": "^7.7.7", 39 | "@babel/parser": "^7.7.7", 40 | "@babel/plugin-transform-typescript": "^7.8.3", 41 | "@babel/types": "^7.7.4", 42 | "@formatjs/intl-unified-numberformat": "^3.1.0", 43 | "globby": "^11.0.0", 44 | "intl-messageformat-parser": "^4.1.1", 45 | "loader-utils": "^2.0.0", 46 | "yargs": "^15.1.0" 47 | }, 48 | "peerDependencies": { 49 | "typescript": "^3.7.5" 50 | }, 51 | "devDependencies": { 52 | "@babel/preset-env": "^7.9.0", 53 | "@babel/preset-react": "^7.9.4", 54 | "@babel/preset-typescript": "^7.8.3", 55 | "@types/babel__code-frame": "^7.0.1", 56 | "@types/jest": "^25.2.1", 57 | "@types/loader-utils": "^1.1.3", 58 | "@types/node": "^13.1.6", 59 | "@types/react": "^16.9.17", 60 | "@types/react-dom": "^16.9.4", 61 | "@types/tmp": "^0.1.0", 62 | "@types/webpack": "^4.41.2", 63 | "@types/yargs": "^15.0.0", 64 | "babel-jest": "^25.1.0", 65 | "jest": "^25.1.0", 66 | "memfs": "^3.0.4", 67 | "react": "^16.12.0", 68 | "react-dom": "^16.12.0", 69 | "tmp": "^0.1.0", 70 | "typescript": "^3.7.5", 71 | "webpack": "^4.41.5" 72 | }, 73 | "gitHead": "62422a94caa37e1780c069950fab8ac7d39df3f9" 74 | } 75 | -------------------------------------------------------------------------------- /packages/nymus/src/Module.ts: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import Scope from './Scope'; 3 | import { CreateModuleOptions } from '.'; 4 | import createComponent, { FormatOptions } from './createComponent'; 5 | import * as astUtil from './astUtil'; 6 | import { Formats, mergeFormats } from './formats'; 7 | 8 | function getIntlFormatter(type: keyof Formats): string { 9 | switch (type) { 10 | case 'number': 11 | return 'NumberFormat'; 12 | case 'date': 13 | return 'DateTimeFormat'; 14 | case 'time': 15 | return 'DateTimeFormat'; 16 | } 17 | } 18 | 19 | interface Export { 20 | localName: string; 21 | ast: t.Statement; 22 | } 23 | 24 | interface Formatter { 25 | localName: string; 26 | type: keyof Formats; 27 | style: string; 28 | } 29 | 30 | interface SharedConst { 31 | localName: string; 32 | init: t.Expression; 33 | } 34 | 35 | export type ModuleTarget = 'react' | 'string'; 36 | 37 | export default class Module { 38 | readonly target: ModuleTarget; 39 | readonly scope: Scope; 40 | readonly exports: Map; 41 | readonly formatters: Map; 42 | readonly _sharedConsts: Map; 43 | readonly locale?: string; 44 | readonly formats: Formats; 45 | 46 | constructor(options: CreateModuleOptions) { 47 | this.target = options.target || 'react'; 48 | this.scope = new Scope(); 49 | if (this.target === 'react') { 50 | this.scope.createBinding('React'); 51 | } 52 | this.exports = new Map(); 53 | this.formatters = new Map(); 54 | this._sharedConsts = new Map(); 55 | this.locale = options.locale; 56 | this.formats = mergeFormats(options.formats || {}); 57 | } 58 | 59 | _useSharedConst( 60 | key: string, 61 | name: string, 62 | build: () => t.Expression 63 | ): t.Identifier { 64 | const sharedConst = this._sharedConsts.get(key); 65 | if (sharedConst) { 66 | return t.identifier(sharedConst.localName); 67 | } 68 | 69 | const localName = this.scope.createUniqueBinding(name); 70 | this._sharedConsts.set(key, { localName, init: build() }); 71 | return t.identifier(localName); 72 | } 73 | 74 | buildFormatterAst(constructor: string, options?: astUtil.Json) { 75 | return t.newExpression( 76 | t.memberExpression(t.identifier('Intl'), t.identifier(constructor)), 77 | [ 78 | this.locale ? t.stringLiteral(this.locale) : t.identifier('undefined'), 79 | options ? astUtil.buildJson(options) : t.identifier('undefined'), 80 | ] 81 | ); 82 | } 83 | 84 | useFormatter( 85 | type: keyof Formats, 86 | style: string | FormatOptions 87 | ): t.Identifier { 88 | const sharedKey = JSON.stringify(['formatter', type, style]); 89 | 90 | return this._useSharedConst(sharedKey, type, () => { 91 | return this.buildFormatterAst( 92 | getIntlFormatter(type), 93 | typeof style === 'string' ? this.formats[type][style] : style 94 | ); 95 | }); 96 | } 97 | 98 | usePlural(type?: 'ordinal' | 'cardinal'): t.Identifier { 99 | const sharedKey = JSON.stringify(['plural', type]); 100 | 101 | return this._useSharedConst(sharedKey, `p_${type}`, () => { 102 | return this.buildFormatterAst('PluralRules', type ? { type } : undefined); 103 | }); 104 | } 105 | 106 | addMessage(componentName: string, message: string) { 107 | if (this.exports.has(componentName)) { 108 | throw new Error( 109 | `A component named "${componentName}" was already defined` 110 | ); 111 | } 112 | 113 | const localName = this.scope.hasBinding(componentName) 114 | ? this.scope.createUniqueBinding(componentName) 115 | : this.scope.createBinding(componentName); 116 | 117 | const { ast } = createComponent(localName, message, this); 118 | 119 | this.exports.set(componentName, { localName, ast }); 120 | } 121 | 122 | _buildSharedConstAst(sharedConst: SharedConst): t.Statement { 123 | return t.variableDeclaration('const', [ 124 | t.variableDeclarator( 125 | t.identifier(sharedConst.localName), 126 | sharedConst.init 127 | ), 128 | ]); 129 | } 130 | 131 | buildModuleAst() { 132 | const formatterDeclarations = Array.from( 133 | this._sharedConsts.values(), 134 | (sharedConst) => this._buildSharedConstAst(sharedConst) 135 | ); 136 | const componentDeclarations: t.Statement[] = []; 137 | const exportSpecifiers: t.ExportSpecifier[] = []; 138 | const displayNames: t.Statement[] = []; 139 | for (const [componentName, { localName, ast }] of this.exports.entries()) { 140 | componentDeclarations.push(ast); 141 | exportSpecifiers.push( 142 | t.exportSpecifier(t.identifier(localName), t.identifier(componentName)) 143 | ); 144 | if (localName !== componentName) { 145 | displayNames.push( 146 | t.expressionStatement( 147 | t.assignmentExpression( 148 | '=', 149 | t.memberExpression( 150 | t.identifier(localName), 151 | t.identifier('displayName') 152 | ), 153 | t.stringLiteral(componentName) 154 | ) 155 | ) 156 | ); 157 | } 158 | } 159 | return [ 160 | ...(this.target === 'react' 161 | ? [ 162 | t.importDeclaration( 163 | [t.importNamespaceSpecifier(t.identifier('React'))], 164 | t.stringLiteral('react') 165 | ), 166 | ] 167 | : []), 168 | ...formatterDeclarations, 169 | ...componentDeclarations, 170 | ...displayNames, 171 | t.exportNamedDeclaration(null, exportSpecifiers), 172 | ]; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /packages/nymus/src/Scope.ts: -------------------------------------------------------------------------------- 1 | import { toIdentifier } from '@babel/types'; 2 | 3 | interface Binding {} 4 | 5 | const CONTEXT_VARIABLES = new Set([ 6 | 'arguments', 7 | 'undefined', 8 | 'Infinity', 9 | 'NaN', 10 | ]); 11 | 12 | export default class Scope { 13 | _parent?: Scope; 14 | _bindings: Map; 15 | 16 | constructor(parent?: Scope) { 17 | this._parent = parent; 18 | this._bindings = new Map(); 19 | } 20 | 21 | createBinding(name: string): string { 22 | if (this.hasOwnBinding(name)) { 23 | throw new Error(`Binding "${name}" already exists`); 24 | } 25 | this._bindings.set(name, {}); 26 | return name; 27 | } 28 | 29 | hasBinding(name: string): boolean { 30 | return ( 31 | CONTEXT_VARIABLES.has(name) || 32 | this.hasOwnBinding(name) || 33 | (this._parent && this._parent.hasBinding(name)) || 34 | false 35 | ); 36 | } 37 | 38 | hasOwnBinding(name: string): boolean { 39 | return this._bindings.has(name); 40 | } 41 | 42 | generateUid(name: string = 'tmp'): string { 43 | // remove leading and trailing underscores 44 | const idBase = toIdentifier(name).replace(/^_+/, '').replace(/_+$/, ''); 45 | let uid; 46 | let i = 0; 47 | do { 48 | uid = `_${idBase}${i > 0 ? `_${i}` : ''}`; 49 | i++; 50 | } while (this.hasBinding(uid)); 51 | return uid; 52 | } 53 | 54 | createUniqueBinding(name: string): string { 55 | const uniqueName = this.generateUid(name); 56 | return this.createBinding(uniqueName); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /packages/nymus/src/TransformationError.ts: -------------------------------------------------------------------------------- 1 | import { SourceLocation } from '@babel/code-frame'; 2 | 3 | export default class TransformationError extends Error { 4 | location: SourceLocation | null; 5 | constructor(message: string, location: SourceLocation | null) { 6 | super(message); 7 | this.location = location; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/nymus/src/__fixtures__/invalid-json.json: -------------------------------------------------------------------------------- 1 | { 2 | "invalid" 3 | -------------------------------------------------------------------------------- /packages/nymus/src/__fixtures__/invalid-message.json: -------------------------------------------------------------------------------- 1 | { 2 | "invalid": {} 3 | } 4 | -------------------------------------------------------------------------------- /packages/nymus/src/__fixtures__/strings/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Hello world" 3 | } 4 | -------------------------------------------------------------------------------- /packages/nymus/src/__fixtures__/strings/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Bonjour monde" 3 | } 4 | -------------------------------------------------------------------------------- /packages/nymus/src/__fixtures__/strings/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "message": "Hallo wereld" 3 | } 4 | -------------------------------------------------------------------------------- /packages/nymus/src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`error formatting error snapshot 1`] = ` 4 | "> 1 | unclosed {argument message 5 | | ^ Expected \\",\\" but \\"m\\" found." 6 | `; 7 | 8 | exports[`error formatting error snapshot 2`] = ` 9 | "> 1 | foo 10 | | ^ Expected \\"#\\", \\"'\\", \\"\\\\n\\", \\"{\\", argumentElement, double apostrophes, end of input, or tagElement but \\"<\\" found." 11 | `; 12 | 13 | exports[`error formatting error snapshot 3`] = ` 14 | "> 1 | foo 15 | | ^ Expected \\"#\\", \\"'\\", \\"\\\\n\\", \\"{\\", argumentElement, double apostrophes, end of input, or tagElement but \\"<\\" found." 16 | `; 17 | 18 | exports[`error formatting error snapshot 4`] = ` 19 | " 1 | 20 | > 2 | {gender, select, 21 | | ^^^^^^^^^^^^^^^^ 22 | > 3 | male {He} 23 | | ^^^^^^^^^^^^^^^ 24 | > 4 | } 25 | | ^^^^^^ A select element requires an \\"other\\" 26 | 5 | " 27 | `; 28 | 29 | exports[`error formatting error snapshot 5`] = ` 30 | "> 1 | <>foo {bar} baz 31 | | ^ Expected \\"#\\", \\"'\\", \\"\\\\n\\", \\"{\\", argumentElement, double apostrophes, end of input, or tagElement but \\"<\\" found." 32 | `; 33 | 34 | exports[`error formatting error snapshot 6`] = ` 35 | "> 1 | 36 | | ^^^^^^^^^^^^^^^^^^^^^^^^^ \\"hyphen-tag\\" is not a valid identifier" 37 | `; 38 | -------------------------------------------------------------------------------- /packages/nymus/src/astUtil.ts: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | 3 | export type Json = 4 | | undefined 5 | | string 6 | | number 7 | | boolean 8 | | null 9 | | Json[] 10 | | { [key: string]: Json }; 11 | 12 | /** 13 | * Build an AST for a literal JSON value 14 | */ 15 | export function buildJson(value: Json): t.Expression { 16 | if (typeof value === 'string') { 17 | return t.stringLiteral(value); 18 | } else if (typeof value === 'number') { 19 | return t.numericLiteral(value); 20 | } else if (typeof value === 'boolean') { 21 | return t.booleanLiteral(value); 22 | } else if (Array.isArray(value)) { 23 | return t.arrayExpression(value.map(buildJson)); 24 | } else if (value === null) { 25 | return t.nullLiteral(); 26 | } else if (value === undefined) { 27 | return t.identifier('undefined'); 28 | } else if (typeof value === 'object') { 29 | return t.objectExpression( 30 | Object.entries(value).map(([propKey, propValue]) => { 31 | return t.objectProperty(t.identifier(propKey), buildJson(propValue)); 32 | }) 33 | ); 34 | } 35 | throw new Error(`value type not supported "${value}"`); 36 | } 37 | 38 | /** 39 | * Build an AST for the expression: `React.createElement(element, null, ...children)` 40 | */ 41 | export function buildReactElement( 42 | element: t.Expression, 43 | children: t.Expression[] 44 | ) { 45 | return t.callExpression( 46 | t.memberExpression(t.identifier('React'), t.identifier('createElement')), 47 | [element, t.nullLiteral(), ...children] 48 | ); 49 | } 50 | 51 | /** 52 | * builds the AST for chained ternaries: 53 | * @example 54 | * test1 55 | * ? consequent1 56 | * : test2 57 | * ? consequent2 58 | * // ... 59 | * : alternate 60 | */ 61 | export function buildTernaryChain( 62 | cases: { test: t.Expression; consequent: t.Expression }[], 63 | alternate: t.Expression 64 | ): t.Expression { 65 | if (cases.length <= 0) { 66 | return alternate; 67 | } 68 | const [{ test, consequent }, ...restCases] = cases; 69 | return t.conditionalExpression( 70 | test, 71 | consequent, 72 | buildTernaryChain(restCases, alternate) 73 | ); 74 | } 75 | 76 | /** 77 | * Build an AST for chaining binary expressions 78 | * @example 79 | * a + b + c + d + e + ... 80 | */ 81 | export function buildBinaryChain( 82 | operator: t.BinaryExpression['operator'], 83 | ...operands: t.Expression[] 84 | ): t.BinaryExpression { 85 | if (operands.length < 2) { 86 | throw new Error( 87 | 'buildBinaryChain should be called with at least 2 operands' 88 | ); 89 | } else if (operands.length === 2) { 90 | return t.binaryExpression(operator, operands[0], operands[1]); 91 | } else { 92 | const rest = operands.slice(0, -1); 93 | const last = operands[operands.length - 1]; 94 | return t.binaryExpression( 95 | operator, 96 | buildBinaryChain(operator, ...rest), 97 | last 98 | ); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/nymus/src/cli.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import * as childProcess from 'child_process'; 4 | import * as path from 'path'; 5 | import { tmpDirFromTemplate, fileExists } from './fileUtil'; 6 | 7 | function exec(cwd: string, command: string) { 8 | return new Promise((resolve) => { 9 | childProcess.exec(command, { cwd }, (error, stdout) => { 10 | resolve({ 11 | code: error ? error.code : null, 12 | stdout: stdout.trim(), 13 | }); 14 | }); 15 | }); 16 | } 17 | 18 | describe('cli', () => { 19 | let fixtureDir; 20 | 21 | function fixturePath(src: string) { 22 | return path.resolve(fixtureDir.path, src); 23 | } 24 | 25 | beforeEach(async () => { 26 | fixtureDir = await tmpDirFromTemplate( 27 | path.resolve(__dirname, './__fixtures__') 28 | ); 29 | }); 30 | 31 | afterEach(() => { 32 | fixtureDir.cleanup(); 33 | }); 34 | 35 | it('should fail on invalid json', async () => { 36 | await expect( 37 | exec(fixtureDir.path, 'nymus ./invalid-json.json') 38 | ).resolves.toMatchObject({ 39 | code: 1, 40 | stdout: `Unexpected end of JSON input`, 41 | }); 42 | }); 43 | 44 | it('should fail on invalid message', async () => { 45 | await expect( 46 | exec(fixtureDir.path, 'nymus ./invalid-message.json') 47 | ).resolves.toMatchObject({ 48 | code: 1, 49 | stdout: `Invalid JSON, "invalid" is not a string`, 50 | }); 51 | }); 52 | 53 | it('should compile a folder', async () => { 54 | await exec(fixtureDir.path, 'nymus ./strings/'); 55 | expect(await fileExists(fixturePath('./strings/en.js'))).toBe(true); 56 | expect(await fileExists(fixturePath('./strings/nl.js'))).toBe(true); 57 | expect(await fileExists(fixturePath('./strings/fr.js'))).toBe(true); 58 | }); 59 | 60 | it('should compile individual files', async () => { 61 | await exec(fixtureDir.path, 'nymus ./strings/nl.json'); 62 | expect(await fileExists(fixturePath('./strings/en.js'))).toBe(false); 63 | expect(await fileExists(fixturePath('./strings/nl.js'))).toBe(true); 64 | expect(await fileExists(fixturePath('./strings/fr.js'))).toBe(false); 65 | }); 66 | 67 | it('should compile glob patterns', async () => { 68 | await exec(fixtureDir.path, 'nymus ./strings/{en,fr}.json'); 69 | expect(await fileExists(fixturePath('./strings/en.js'))).toBe(true); 70 | expect(await fileExists(fixturePath('./strings/nl.js'))).toBe(false); 71 | expect(await fileExists(fixturePath('./strings/fr.js'))).toBe(true); 72 | }); 73 | 74 | it('should only compile javascript when no configuration', async () => { 75 | await exec(fixtureDir.path, 'nymus ./strings/nl.json'); 76 | expect(await fileExists(fixturePath('./strings/nl.js'))).toBe(true); 77 | expect(await fileExists(fixturePath('./strings/nl.ts'))).toBe(false); 78 | expect(await fileExists(fixturePath('./strings/nl.d.ts'))).toBe(false); 79 | }); 80 | 81 | it('should compile declarations when configured', async () => { 82 | await exec(fixtureDir.path, 'nymus -d ./strings/nl.json'); 83 | expect(await fileExists(fixturePath('./strings/nl.js'))).toBe(true); 84 | expect(await fileExists(fixturePath('./strings/nl.ts'))).toBe(false); 85 | expect(await fileExists(fixturePath('./strings/nl.d.ts'))).toBe(true); 86 | }); 87 | 88 | it('should compile typescript when configured', async () => { 89 | await exec(fixtureDir.path, 'nymus -t ./strings/nl.json'); 90 | expect(await fileExists(fixturePath('./strings/nl.js'))).toBe(false); 91 | expect(await fileExists(fixturePath('./strings/nl.ts'))).toBe(true); 92 | expect(await fileExists(fixturePath('./strings/nl.d.ts'))).toBe(false); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /packages/nymus/src/cli.ts: -------------------------------------------------------------------------------- 1 | import * as yargs from 'yargs'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import createModule, { CreateModuleOptions } from './index'; 5 | import { promisify } from 'util'; 6 | import * as globby from 'globby'; 7 | 8 | const fsReadFile = promisify(fs.readFile); 9 | const fsWriteFile = promisify(fs.writeFile); 10 | 11 | const { argv } = yargs 12 | .usage('Usage: $0 [options] [file...]') 13 | .example('$0 --locale en ./string-en.json', '') 14 | .option('locale', { 15 | type: 'string', 16 | description: 'The locale to use for formatters', 17 | alias: 'l', 18 | requiresArg: true, 19 | }) 20 | .option('typescript', { 21 | type: 'boolean', 22 | description: 'Emit typescript instead of javascript', 23 | alias: 't', 24 | }) 25 | .option('declarations', { 26 | type: 'boolean', 27 | description: 'Emit type declarations (.d.ts)', 28 | alias: 'd', 29 | }); /* 30 | .option('output-dir', { 31 | type: 'string', 32 | description: 'The directory where transformed files should be stored', 33 | alias: 'o' 34 | }) 35 | .option('source-root', { 36 | type: 'string', 37 | description: 38 | 'The directory where the source files are considered relative from' 39 | }) */ 40 | 41 | function getOutputDirectory(srcPath: string): string { 42 | return path.dirname(srcPath); 43 | } 44 | 45 | async function transformFile(srcPath: string, options: CreateModuleOptions) { 46 | const content = await fsReadFile(srcPath, { encoding: 'utf-8' }); 47 | const messages = JSON.parse(content); 48 | const { code, declarations } = await createModule(messages, options); 49 | return { code, declarations }; 50 | } 51 | 52 | async function main() { 53 | if (argv._.length <= 0) { 54 | throw new Error('missing input'); 55 | } 56 | const resolvedFiles = await globby(argv._); 57 | await Promise.all( 58 | resolvedFiles.map(async (resolvedFile) => { 59 | const { code, declarations } = await transformFile(resolvedFile, { 60 | ...argv, 61 | // force this for now 62 | target: 'react', 63 | }); 64 | const outputDirectory = getOutputDirectory(resolvedFile); 65 | const fileName = path.basename(resolvedFile); 66 | const extension = path.extname(resolvedFile); 67 | const fileBaseName = 68 | extension.length <= 0 ? fileName : fileName.slice(0, -extension.length); 69 | const outputExtension = argv.typescript ? '.ts' : '.js'; 70 | const outputPath = path.join( 71 | outputDirectory, 72 | fileBaseName + outputExtension 73 | ); 74 | const declarationsPath = path.join( 75 | outputDirectory, 76 | fileBaseName + '.d.ts' 77 | ); 78 | await Promise.all([ 79 | fsWriteFile(outputPath, code, { encoding: 'utf-8' }), 80 | declarations 81 | ? fsWriteFile(declarationsPath, declarations, { encoding: 'utf-8' }) 82 | : null, 83 | ]); 84 | }) 85 | ); 86 | } 87 | 88 | main().catch((error) => { 89 | console.log(error.message); 90 | process.exit(1); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/nymus/src/createComponent.ts: -------------------------------------------------------------------------------- 1 | import * as mf from 'intl-messageformat-parser'; 2 | import * as t from '@babel/types'; 3 | import Scope from './Scope'; 4 | import TransformationError from './TransformationError'; 5 | import Module from './Module'; 6 | import * as astUtil from './astUtil'; 7 | import { Formats } from './formats'; 8 | import { UnifiedNumberFormatOptions } from '@formatjs/intl-unified-numberformat'; 9 | 10 | type ToType = { 11 | [K in keyof F]: F[K]; 12 | }; 13 | 14 | export type FormatOptions = ToType< 15 | UnifiedNumberFormatOptions | mf.ExtendedDateTimeFormatOptions 16 | >; 17 | 18 | interface LiteralFragment { 19 | type: 'literal'; 20 | value: string; 21 | isJsx: false; 22 | } 23 | 24 | interface ExpressionFragment { 25 | type: 'dynamic'; 26 | value: t.Expression; 27 | isJsx: boolean; 28 | } 29 | 30 | type TemplateFragment = LiteralFragment | ExpressionFragment; 31 | 32 | function createLiteralFragment(value: string): LiteralFragment { 33 | return { 34 | type: 'literal', 35 | value, 36 | isJsx: false, 37 | }; 38 | } 39 | 40 | function createExpressionFragment( 41 | value: t.Expression, 42 | isJsx: boolean 43 | ): ExpressionFragment { 44 | return { 45 | type: 'dynamic', 46 | value, 47 | isJsx, 48 | }; 49 | } 50 | 51 | interface Argument { 52 | localName?: string; 53 | type: ArgumentType; 54 | } 55 | 56 | function icuNodesToExpression( 57 | icuNodes: mf.MessageFormatElement[], 58 | context: ComponentContext 59 | ): t.Expression { 60 | const fragments = icuNodes.map((icuNode) => 61 | icuNodeToJsFragment(icuNode, context) 62 | ); 63 | 64 | const containsJsx = fragments.some((fragment) => fragment.isJsx); 65 | 66 | if (containsJsx) { 67 | if (context.target !== 'react') { 68 | throw new Error( 69 | "Invariant: a fragment shouldn't be jsx when a string template is generated" 70 | ); 71 | } 72 | return t.jsxFragment( 73 | t.jsxOpeningFragment(), 74 | t.jsxClosingFragment(), 75 | fragments.map((fragment) => { 76 | switch (fragment.type) { 77 | case 'literal': 78 | return t.jsxText(fragment.value); 79 | case 'dynamic': 80 | return t.jsxExpressionContainer(fragment.value); 81 | } 82 | }) 83 | ); 84 | } else { 85 | if (fragments.length <= 0) { 86 | return t.stringLiteral(''); 87 | } else if (fragments.length === 1 && fragments[0].type === 'literal') { 88 | return t.stringLiteral(fragments[0].value); 89 | } 90 | return astUtil.buildBinaryChain( 91 | '+', 92 | t.stringLiteral(''), 93 | ...fragments.map((fragment) => { 94 | switch (fragment.type) { 95 | case 'literal': 96 | return t.stringLiteral(fragment.value); 97 | case 'dynamic': 98 | return fragment.value; 99 | } 100 | }) 101 | ); 102 | } 103 | } 104 | 105 | function buildFormatterCall(formatter: t.Identifier, value: t.Identifier) { 106 | return t.callExpression( 107 | t.memberExpression(formatter, t.identifier('format')), 108 | [value] 109 | ); 110 | } 111 | 112 | function buildPluralRulesCall(formatter: t.Identifier, value: t.Identifier) { 113 | return t.callExpression( 114 | t.memberExpression(formatter, t.identifier('select')), 115 | [value] 116 | ); 117 | } 118 | 119 | function icuLiteralElementToFragment( 120 | elm: mf.LiteralElement, 121 | context: ComponentContext 122 | ) { 123 | return createLiteralFragment(elm.value); 124 | } 125 | 126 | function icuArgumentElementToFragment( 127 | elm: mf.ArgumentElement, 128 | context: ComponentContext 129 | ) { 130 | const localIdentifier = context.addArgument(elm.value, ArgumentType.Text); 131 | return createExpressionFragment(localIdentifier, context.target === 'react'); 132 | } 133 | 134 | function icuSelectElementToFragment( 135 | elm: mf.SelectElement, 136 | context: ComponentContext 137 | ) { 138 | const argIdentifier = context.addArgument(elm.value, ArgumentType.string); 139 | if (!elm.options.hasOwnProperty('other')) { 140 | throw new TransformationError( 141 | 'A select element requires an "other"', 142 | elm.location || null 143 | ); 144 | } 145 | const { other, ...options } = elm.options; 146 | const cases = Object.entries(options).map(([name, caseNode]) => ({ 147 | test: t.binaryExpression('===', argIdentifier, t.stringLiteral(name)), 148 | consequent: icuNodesToExpression(caseNode.value, context), 149 | })); 150 | const otherFragment = icuNodesToExpression(other.value, context); 151 | return createExpressionFragment( 152 | astUtil.buildTernaryChain(cases, otherFragment), 153 | false 154 | ); 155 | } 156 | 157 | function icuPluralElementToFragment( 158 | elm: mf.PluralElement, 159 | context: ComponentContext 160 | ) { 161 | const argIdentifier = context.addArgument(elm.value, ArgumentType.number); 162 | const formatted = context.useFormattedValue( 163 | argIdentifier, 164 | 'number', 165 | 'decimal' 166 | ); 167 | context.enterPlural(formatted); 168 | if (!elm.options.hasOwnProperty('other')) { 169 | throw new TransformationError( 170 | 'A plural element requires an "other"', 171 | elm.location || null 172 | ); 173 | } 174 | const { other, ...options } = elm.options; 175 | const otherFragment = icuNodesToExpression(other.value, context); 176 | const withOffset = context.useWithOffset(argIdentifier, elm.offset); 177 | const localized = context.useLocalizedMatcher(withOffset, elm.pluralType); 178 | const cases = Object.entries(options).map(([name, caseNode]) => { 179 | const test = name.startsWith('=') 180 | ? t.binaryExpression( 181 | '===', 182 | withOffset, 183 | t.numericLiteral(Number(name.slice(1))) 184 | ) 185 | : t.binaryExpression('===', localized, t.stringLiteral(name)); 186 | return { test, consequent: icuNodesToExpression(caseNode.value, context) }; 187 | }); 188 | context.exitPlural(); 189 | return createExpressionFragment( 190 | astUtil.buildTernaryChain(cases, otherFragment), 191 | false 192 | ); 193 | } 194 | 195 | function icuNumberElementToFragment( 196 | elm: mf.NumberElement, 197 | context: ComponentContext 198 | ) { 199 | const value = context.addArgument(elm.value, ArgumentType.number); 200 | const style = mf.isNumberSkeleton(elm.style) 201 | ? mf.convertNumberSkeletonToNumberFormatOptions(elm.style.tokens) 202 | : elm.style || 'decimal'; 203 | return createExpressionFragment( 204 | context.useFormattedValue(value, 'number', style), 205 | false 206 | ); 207 | } 208 | 209 | function icuDateElementToFragment( 210 | elm: mf.DateElement, 211 | context: ComponentContext 212 | ) { 213 | const value = context.addArgument(elm.value, ArgumentType.Date); 214 | const style = mf.isDateTimeSkeleton(elm.style) 215 | ? mf.parseDateTimeSkeleton(elm.style.pattern) 216 | : elm.style || 'medium'; 217 | return createExpressionFragment( 218 | context.useFormattedValue(value, 'date', style), 219 | false 220 | ); 221 | } 222 | 223 | function icuTimeElementToFragment( 224 | elm: mf.TimeElement, 225 | context: ComponentContext 226 | ) { 227 | const value = context.addArgument(elm.value, ArgumentType.Date); 228 | const style = mf.isDateTimeSkeleton(elm.style) 229 | ? mf.parseDateTimeSkeleton(elm.style.pattern) 230 | : elm.style || 'medium'; 231 | return createExpressionFragment( 232 | context.useFormattedValue(value, 'time', style), 233 | false 234 | ); 235 | } 236 | 237 | function icuPoundElementToFragment( 238 | elm: mf.PoundElement, 239 | context: ComponentContext 240 | ) { 241 | return createExpressionFragment(context.getPound(), false); 242 | } 243 | 244 | function tagElementToFragment(elm: mf.TagElement, context: ComponentContext) { 245 | if (!t.isValidIdentifier(elm.value)) { 246 | throw new TransformationError( 247 | `"${elm.value}" is not a valid identifier`, 248 | elm.location || null 249 | ); 250 | } 251 | const localName = context.addArgument(elm.value, ArgumentType.Markup); 252 | if (context.target === 'react') { 253 | const ast = t.jsxElement( 254 | t.jsxOpeningElement(t.jsxIdentifier(localName.name), [], false), 255 | t.jsxClosingElement(t.jsxIdentifier(localName.name)), 256 | [t.jsxExpressionContainer(icuNodesToExpression(elm.children, context))], 257 | false 258 | ); 259 | return createExpressionFragment(ast, true); 260 | } else { 261 | return createExpressionFragment( 262 | t.callExpression(localName, [ 263 | icuNodesToExpression(elm.children, context), 264 | ]), 265 | false 266 | ); 267 | } 268 | } 269 | 270 | function icuNodeToJsFragment( 271 | icuNode: mf.MessageFormatElement, 272 | context: ComponentContext 273 | ): TemplateFragment { 274 | switch (icuNode.type) { 275 | case mf.TYPE.literal: 276 | return icuLiteralElementToFragment(icuNode, context); 277 | case mf.TYPE.argument: 278 | return icuArgumentElementToFragment(icuNode, context); 279 | case mf.TYPE.select: 280 | return icuSelectElementToFragment(icuNode, context); 281 | case mf.TYPE.plural: 282 | return icuPluralElementToFragment(icuNode, context); 283 | case mf.TYPE.number: 284 | return icuNumberElementToFragment(icuNode, context); 285 | case mf.TYPE.date: 286 | return icuDateElementToFragment(icuNode, context); 287 | case mf.TYPE.time: 288 | return icuTimeElementToFragment(icuNode, context); 289 | case mf.TYPE.pound: 290 | return icuPoundElementToFragment(icuNode, context); 291 | case mf.TYPE.tag: 292 | return tagElementToFragment(icuNode, context); 293 | default: 294 | throw new Error( 295 | `Unknown AST node type ${(icuNode as mf.MessageFormatElement).type}` 296 | ); 297 | } 298 | } 299 | 300 | function createContext(module: Module): ComponentContext { 301 | return new ComponentContext(module); 302 | } 303 | 304 | enum ArgumentType { 305 | string, 306 | number, 307 | Date, 308 | Markup, 309 | Text, 310 | } 311 | 312 | function getTypeAnnotation(type: ArgumentType, context: ComponentContext) { 313 | switch (type) { 314 | case ArgumentType.string: 315 | return t.tsStringKeyword(); 316 | case ArgumentType.number: 317 | return t.tsNumberKeyword(); 318 | case ArgumentType.Date: 319 | return t.tsTypeReference(t.identifier('Date')); 320 | case ArgumentType.Text: 321 | if (context.target === 'react') { 322 | return t.tsTypeReference( 323 | t.tsQualifiedName(t.identifier('React'), t.identifier('ReactNode')) 324 | ); 325 | } else { 326 | return t.tsStringKeyword(); 327 | } 328 | case ArgumentType.Markup: 329 | if (context.target === 'react') { 330 | return t.tsTypeReference( 331 | t.tsQualifiedName(t.identifier('React'), t.identifier('Element')) 332 | ); 333 | } else { 334 | const childrenParam = t.identifier('children'); 335 | childrenParam.typeAnnotation = t.tsTypeAnnotation(t.tsStringKeyword()); 336 | return t.tsFunctionType( 337 | null, 338 | [childrenParam], 339 | t.tsTypeAnnotation(t.tsStringKeyword()) 340 | ); 341 | } 342 | } 343 | } 344 | 345 | interface SharedConst { 346 | localName: string; 347 | init: t.Expression; 348 | } 349 | 350 | class ComponentContext { 351 | _module: Module; 352 | args: Map; 353 | _scope: Scope; 354 | _poundStack: t.Identifier[]; 355 | _sharedConsts: Map; 356 | 357 | constructor(module: Module) { 358 | this._module = module; 359 | this._scope = new Scope(module.scope); 360 | this.args = new Map(); 361 | this._poundStack = []; 362 | this._sharedConsts = new Map(); 363 | } 364 | 365 | enterPlural(identifier: t.Identifier) { 366 | this._poundStack.unshift(identifier); 367 | } 368 | 369 | exitPlural() { 370 | this._poundStack.shift(); 371 | } 372 | 373 | getPound() { 374 | return this._poundStack[0]; 375 | } 376 | 377 | get locale() { 378 | return this._module.locale; 379 | } 380 | 381 | get formats() { 382 | return this._module.formats; 383 | } 384 | 385 | get target() { 386 | return this._module.target; 387 | } 388 | 389 | addArgument(name: string, type: ArgumentType): t.Identifier { 390 | const arg: Argument = { type }; 391 | if (this._scope.hasBinding(name)) { 392 | arg.localName = this._scope.createUniqueBinding(name); 393 | } 394 | this.args.set(name, arg); 395 | return t.identifier(arg.localName || name); 396 | } 397 | 398 | useFormatter( 399 | type: keyof Formats, 400 | style: string | FormatOptions 401 | ): t.Identifier { 402 | return this._module.useFormatter(type, style); 403 | } 404 | 405 | useFormattedValue( 406 | value: t.Identifier, 407 | type: keyof Formats, 408 | style: string | FormatOptions 409 | ): t.Identifier { 410 | const formatterId = this.useFormatter(type, style); 411 | const key = JSON.stringify(['formattedValue', value.name, type, style]); 412 | return this._useSharedConst(key, value.name, () => 413 | buildFormatterCall(formatterId, value) 414 | ); 415 | } 416 | 417 | useWithOffset(value: t.Identifier, offset: number = 0): t.Identifier { 418 | if (offset === 0) { 419 | return value; 420 | } 421 | const key = JSON.stringify(['withOffset', value.name, offset]); 422 | return this._useSharedConst(key, `${value.name}_offset_${offset}`, () => 423 | t.binaryExpression('-', value, t.numericLiteral(offset)) 424 | ); 425 | } 426 | 427 | useLocalizedMatcher( 428 | value: t.Identifier, 429 | pluralType: 'ordinal' | 'cardinal' = 'cardinal' 430 | ): t.Identifier { 431 | const key = JSON.stringify(['localizedMatcher', value.name, pluralType]); 432 | const pluralRules = this.usePlural(pluralType); 433 | 434 | return this._useSharedConst(key, `${value.name}_loc`, () => 435 | buildPluralRulesCall(pluralRules, value) 436 | ); 437 | } 438 | 439 | usePlural(type: 'ordinal' | 'cardinal' = 'cardinal'): t.Identifier { 440 | return this._module.usePlural(type); 441 | } 442 | 443 | _useSharedConst( 444 | key: string, 445 | name: string, 446 | build: () => t.Expression 447 | ): t.Identifier { 448 | const sharedConst = this._sharedConsts.get(key); 449 | if (sharedConst) { 450 | return t.identifier(sharedConst.localName); 451 | } 452 | 453 | const localName = this._scope.createUniqueBinding(name); 454 | this._sharedConsts.set(key, { localName, init: build() }); 455 | return t.identifier(localName); 456 | } 457 | 458 | buildArgsAst() { 459 | if (this.args.size <= 0) { 460 | return []; 461 | } else { 462 | const argsObjectPattern = t.objectPattern( 463 | Array.from(this.args.entries(), ([name, arg]) => { 464 | const key = t.identifier(name); 465 | const value = arg.localName ? t.identifier(arg.localName) : key; 466 | return t.objectProperty(key, value, false, !arg.localName); 467 | }) 468 | ); 469 | argsObjectPattern.typeAnnotation = t.tsTypeAnnotation( 470 | t.tsTypeLiteral( 471 | Array.from(this.args.entries(), ([name, arg]) => { 472 | return t.tsPropertySignature( 473 | t.identifier(name), 474 | t.tsTypeAnnotation(getTypeAnnotation(arg.type, this)) 475 | ); 476 | }) 477 | ) 478 | ); 479 | return [argsObjectPattern]; 480 | } 481 | } 482 | 483 | _buildSharedConstAst(sharedConst: SharedConst): t.Statement { 484 | return t.variableDeclaration('const', [ 485 | t.variableDeclarator( 486 | t.identifier(sharedConst.localName), 487 | sharedConst.init 488 | ), 489 | ]); 490 | } 491 | 492 | buildSharedConstsAst(): t.Statement[] { 493 | return Array.from(this._sharedConsts.values(), (sharedConst) => 494 | this._buildSharedConstAst(sharedConst) 495 | ); 496 | } 497 | } 498 | 499 | export default function icuToReactComponent( 500 | componentName: string, 501 | icuStr: string, 502 | module: Module 503 | ) { 504 | const context = createContext(module); 505 | const icuAst = mf.parse(icuStr, { 506 | captureLocation: true, 507 | }); 508 | const returnValue = icuNodesToExpression(icuAst, context); 509 | const ast = t.functionDeclaration( 510 | t.identifier(componentName), 511 | context.buildArgsAst(), 512 | t.blockStatement([ 513 | ...context.buildSharedConstsAst(), 514 | t.returnStatement(returnValue), 515 | ]) 516 | ); 517 | 518 | return { 519 | ast, 520 | args: context.args, 521 | }; 522 | } 523 | -------------------------------------------------------------------------------- /packages/nymus/src/fileUtil.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'util'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import * as tmp from 'tmp'; 5 | 6 | const fsStat = promisify(fs.stat); 7 | const fsReaddir = promisify(fs.readdir); 8 | const fsCopyFile = promisify(fs.copyFile); 9 | const fsMkdir = promisify(fs.mkdir); 10 | const fsRmdir = promisify(fs.rmdir); 11 | 12 | export async function copyRecursive(src: string, dest: string) { 13 | const stats = await fsStat(src); 14 | const isDirectory = stats.isDirectory(); 15 | if (isDirectory) { 16 | try { 17 | await fsMkdir(dest); 18 | } catch (err) { 19 | if (err.code !== 'EEXIST') { 20 | throw err; 21 | } 22 | } 23 | const entries = await fsReaddir(src); 24 | await Promise.all( 25 | entries.map(async (entry) => { 26 | await copyRecursive(path.join(src, entry), path.join(dest, entry)); 27 | }) 28 | ); 29 | } else { 30 | await fsCopyFile(src, dest); 31 | } 32 | } 33 | 34 | export async function rmDirRecursive(src: string) { 35 | await fsRmdir(src, { recursive: true }); 36 | } 37 | 38 | export async function fileExists(src: string) { 39 | try { 40 | await fsStat(src); 41 | return true; 42 | } catch (err) { 43 | if (err.code === 'ENOENT') { 44 | return false; 45 | } 46 | throw err; 47 | } 48 | } 49 | 50 | interface TmpDir { 51 | path: string; 52 | cleanup: () => void; 53 | } 54 | 55 | export async function tmpDirFromTemplate(templayePath: string) { 56 | const result = await new Promise((resolve, reject) => { 57 | tmp.dir({ unsafeCleanup: true }, (err, path, cleanup) => { 58 | if (err) { 59 | reject(err); 60 | return; 61 | } 62 | resolve({ path, cleanup }); 63 | }); 64 | }); 65 | await copyRecursive(templayePath, result.path); 66 | return result; 67 | } 68 | -------------------------------------------------------------------------------- /packages/nymus/src/formats.ts: -------------------------------------------------------------------------------- 1 | const DEFAULTS = { 2 | number: { 3 | currency: { 4 | style: 'currency', 5 | }, 6 | 7 | percent: { 8 | style: 'percent', 9 | }, 10 | }, 11 | 12 | date: { 13 | short: { 14 | month: 'numeric', 15 | day: 'numeric', 16 | year: '2-digit', 17 | }, 18 | 19 | medium: { 20 | month: 'short', 21 | day: 'numeric', 22 | year: 'numeric', 23 | }, 24 | 25 | long: { 26 | month: 'long', 27 | day: 'numeric', 28 | year: 'numeric', 29 | }, 30 | 31 | full: { 32 | weekday: 'long', 33 | month: 'long', 34 | day: 'numeric', 35 | year: 'numeric', 36 | }, 37 | }, 38 | 39 | time: { 40 | short: { 41 | hour: 'numeric', 42 | minute: 'numeric', 43 | }, 44 | 45 | medium: { 46 | hour: 'numeric', 47 | minute: 'numeric', 48 | second: 'numeric', 49 | }, 50 | 51 | long: { 52 | hour: 'numeric', 53 | minute: 'numeric', 54 | second: 'numeric', 55 | timeZoneName: 'short', 56 | }, 57 | 58 | full: { 59 | hour: 'numeric', 60 | minute: 'numeric', 61 | second: 'numeric', 62 | timeZoneName: 'short', 63 | }, 64 | }, 65 | }; 66 | 67 | interface FormatterStyles { 68 | [style: string]: { 69 | [key: string]: string; 70 | }; 71 | } 72 | 73 | export interface Formats { 74 | number: FormatterStyles; 75 | date: FormatterStyles; 76 | time: FormatterStyles; 77 | } 78 | 79 | export function mergeFormats(...formattersList: Partial[]): Formats { 80 | return { 81 | number: Object.assign( 82 | {}, 83 | DEFAULTS.number, 84 | ...formattersList.map((formatters) => formatters.number) 85 | ), 86 | date: Object.assign( 87 | {}, 88 | DEFAULTS.date, 89 | ...formattersList.map((formatters) => formatters.date) 90 | ), 91 | time: Object.assign( 92 | {}, 93 | DEFAULTS.time, 94 | ...formattersList.map((formatters) => formatters.time) 95 | ), 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /packages/nymus/src/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import { 4 | createComponents, 5 | createComponent, 6 | render, 7 | createTemplate, 8 | } from './testUtils'; 9 | import * as React from 'react'; 10 | import { formatError } from './index'; 11 | 12 | describe('react', () => { 13 | it('fails on invalid json', async () => { 14 | await expect( 15 | createComponents({ 16 | // @ts-ignore We want to test output on invalid input 17 | message: {}, 18 | }) 19 | ).rejects.toHaveProperty( 20 | 'message', 21 | 'Invalid JSON, "message" is not a string' 22 | ); 23 | }); 24 | 25 | it('creates empty component', async () => { 26 | const empty = await createComponent(''); 27 | const result = render(empty); 28 | expect(result).toBe(''); 29 | expect(typeof empty({})).toBe('string'); 30 | }); 31 | 32 | it('creates simple text component', async () => { 33 | const simpleString = await createComponent('x'); 34 | const result = render(simpleString); 35 | expect(result).toBe('x'); 36 | expect(typeof simpleString({})).toBe('string'); 37 | }); 38 | 39 | it('handles ICU arguments', async () => { 40 | const withArguments = await createComponent('x {a} y {b} z'); 41 | const result = render(withArguments, { a: '1', b: '2' }); 42 | expect(result).toBe('x 1 y 2 z'); 43 | }); 44 | 45 | it('handles single argument only', async () => { 46 | const singleArg = await createComponent('{a}'); 47 | const result = render(singleArg, { a: '1' }); 48 | expect(result).toBe('1'); 49 | }); 50 | 51 | it('handles twice defined ICU arguments', async () => { 52 | const argsTwice = await createComponent('{a} {a}'); 53 | const result = render(argsTwice, { a: '1' }); 54 | expect(result).toBe('1 1'); 55 | }); 56 | 57 | it('handles numeric input concatenation correctly', async () => { 58 | const argsTwice = await createComponent('{a}{b}'); 59 | const result = render(argsTwice, { a: 2, b: 3 }); 60 | expect(result).toBe('23'); 61 | }); 62 | 63 | it("Doesn't fail on React named component", async () => { 64 | const React = await createComponent('react'); 65 | const result = render(React); 66 | expect(result).toBe('react'); 67 | }); 68 | 69 | it('do select expressions', async () => { 70 | const withSelect = await createComponent( 71 | '{gender, select, male{He} female{She} other{They}}' 72 | ); 73 | const maleResult = render(withSelect, { 74 | gender: 'male', 75 | }); 76 | expect(maleResult).toBe('He'); 77 | const femaleResult = render(withSelect, { 78 | gender: 'female', 79 | }); 80 | expect(femaleResult).toBe('She'); 81 | const otherResult = render(withSelect, { 82 | gender: 'whatever', 83 | }); 84 | expect(otherResult).toBe('They'); 85 | 86 | expect(typeof withSelect({ gender: 'male' })).toBe('string'); 87 | }); 88 | 89 | it('can nest select expressions', async () => { 90 | const nestedSelect = await createComponent(`a{x, select, 91 | a1 {b{y, select, 92 | a11 {g} 93 | a12 {h} 94 | other {} 95 | }d} 96 | a2 {c{z, select, 97 | a21 {i} 98 | a22 {j} 99 | other {} 100 | }e} 101 | other {} 102 | }f`); 103 | expect(render(nestedSelect, { x: 'a1', y: 'a11' })).toBe('abgdf'); 104 | expect(render(nestedSelect, { x: 'a1', y: 'a12' })).toBe('abhdf'); 105 | expect(render(nestedSelect, { x: 'a2', z: 'a21' })).toBe('acief'); 106 | expect(render(nestedSelect, { x: 'a2', z: 'a22' })).toBe('acjef'); 107 | }); 108 | 109 | it('can format numbers and dates', async () => { 110 | const msg = await createComponent( 111 | 'At {theDate, time, medium} on {theDate, date, medium}, there was {text} on planet {planet, number, decimal}.' 112 | ); 113 | const result = render(msg, { 114 | theDate: new Date(1507216343344), 115 | text: 'a disturbance in the Force', 116 | planet: 7, 117 | }); 118 | expect(result).toBe( 119 | 'At 5:12:23 PM on Oct 5, 2017, there was a disturbance in the Force on planet 7.' 120 | ); 121 | }); 122 | 123 | it('can format dates from numbers', async () => { 124 | const msg = await createComponent('On {theDate, date, medium}.'); 125 | const result = render(msg, { theDate: 1507216343344 }); 126 | expect(result).toBe('On Oct 5, 2017.'); 127 | }); 128 | 129 | it('makes string returning components for numbers, dates, times and pounds', async () => { 130 | const msg = await createComponent( 131 | '{today, date}, {today, time}, {count, number}, {count, plural, other {#}}' 132 | ); 133 | 134 | const result = render(msg, { 135 | today: new Date(1507216343344), 136 | count: 123, 137 | }); 138 | 139 | expect(result).toBe('Oct 5, 2017, 5:12:23 PM, 123, 123'); 140 | expect( 141 | typeof msg({ 142 | today: new Date(1507216343344), 143 | count: 123, 144 | }) 145 | ).toBe('string'); 146 | }); 147 | 148 | it('handles number skeleton with goup-off', async () => { 149 | const msg = await createComponent( 150 | '{amount, number, ::currency/CAD .0 group-off}', 151 | { locale: 'en-US' } 152 | ); 153 | 154 | const result = render(msg, { amount: 123456.78 }); 155 | expect(result).toBe('CA$123456.8'); 156 | }); 157 | 158 | it('handles number skeleton', async () => { 159 | const msg = await createComponent('{amount, number, ::currency/GBP .0#}', { 160 | locale: 'en-US', 161 | }); 162 | 163 | const result = render(msg, { amount: 123456.789 }); 164 | expect(result).toBe('£123,456.79'); 165 | }); 166 | 167 | it('handles date skeleton', async () => { 168 | const msg = await createComponent( 169 | "{today, date, ::hh 'o''clock' a, zzzz}", 170 | { 171 | locale: 'en-US', 172 | } 173 | ); 174 | 175 | const result = render(msg, { today: new Date(1579940163111) }); 176 | expect(result).toBe('09 AM Central European Standard Time'); 177 | }); 178 | 179 | it('custom formats should work for time', async () => { 180 | const msg = await createComponent('Today is {time, time, verbose}', { 181 | formats: { 182 | time: { 183 | verbose: { 184 | month: 'long', 185 | day: 'numeric', 186 | year: 'numeric', 187 | hour: 'numeric', 188 | minute: 'numeric', 189 | second: 'numeric', 190 | timeZoneName: 'short', 191 | }, 192 | }, 193 | }, 194 | }); 195 | const result = render(msg, { time: new Date(0) }); 196 | expect(result).toBe('Today is January 1, 1970, 1:00:00 AM GMT+1'); 197 | }); 198 | 199 | it('can format percentages', async () => { 200 | const msg = await createComponent('Score: {percentage, number, percent}.'); 201 | const result = render(msg, { 202 | percentage: 0.6549, 203 | }); 204 | expect(result).toBe('Score: 65%.'); 205 | expect(typeof msg({ percentage: 0.6549 })).toBe('string'); 206 | }); 207 | 208 | it('can reuse formatters', async () => { 209 | const spy = jest.spyOn(Intl, 'NumberFormat'); 210 | try { 211 | const msg = await createComponent( 212 | 'Score: {score, number, percent}, Maximum: {max, number, percent}.', 213 | {}, 214 | Intl 215 | ); 216 | const result = render(msg, { 217 | score: 0.6549, 218 | max: 0.9436, 219 | }); 220 | expect(spy).toHaveBeenCalledTimes(1); 221 | expect(result).toBe('Score: 65%, Maximum: 94%.'); 222 | expect( 223 | typeof msg({ 224 | score: 0.6549, 225 | max: 0.9436, 226 | }) 227 | ).toBe('string'); 228 | } finally { 229 | spy.mockRestore(); 230 | } 231 | }); 232 | 233 | it('can reuse formatted values', async () => { 234 | // TODO: find way to count number of .format calls 235 | const msg = await createComponent( 236 | 'Score: {score, number, percent}, Maximum: {score, number, percent}.', 237 | {}, 238 | Intl 239 | ); 240 | const result = render(msg, { score: 0.6549 }); 241 | expect(result).toBe('Score: 65%, Maximum: 65%.'); 242 | }); 243 | 244 | it('can format currencies', async () => { 245 | const msg = await createComponent('It costs {amount, number, USD}.', { 246 | locale: 'en-US', 247 | formats: { 248 | number: { 249 | USD: { 250 | style: 'currency', 251 | currency: 'USD', 252 | }, 253 | }, 254 | }, 255 | }); 256 | const result = render(msg, { 257 | amount: 123.456, 258 | }); 259 | expect(result).toBe('It costs $123.46.'); 260 | }); 261 | 262 | it('should handle @ correctly', async () => { 263 | const msg = await createComponent('hi @{there}', { locale: 'en' }); 264 | expect( 265 | render(msg, { 266 | there: '2008', 267 | }) 268 | ).toBe('hi @2008'); 269 | }); 270 | 271 | describe('ported intl-messageformat tests', () => { 272 | describe('using a string pattern', () => { 273 | it('should properly replace direct arguments in the string', async () => { 274 | const mf = await createComponent('My name is {FIRST} {LAST}.'); 275 | const output = render(mf, { FIRST: 'Anthony', LAST: 'Pipkin' }); 276 | expect(output).toBe('My name is Anthony Pipkin.'); 277 | }); 278 | 279 | it('should not ignore zero values', async () => { 280 | const mf = await createComponent('I am {age} years old.'); 281 | const output = render(mf, { age: 0 }); 282 | expect(output).toBe('I am 0 years old.'); 283 | }); 284 | 285 | it('should render false, null, and undefined like react renders them', async () => { 286 | const mf = await createComponent('{a} {b} {c} {d}'); 287 | const output = render(mf, { 288 | a: false, 289 | b: null, 290 | c: 0, 291 | d: undefined, 292 | }); 293 | expect(output).toBe(' 0 '); 294 | }); 295 | }); 296 | 297 | describe('and plurals under the Arabic locale', () => { 298 | const msg = 299 | '' + 300 | 'I have {numPeople, plural,' + 301 | 'zero {zero points}' + 302 | 'one {a point}' + 303 | 'two {two points}' + 304 | 'few {a few points}' + 305 | 'many {lots of points}' + 306 | 'other {some other amount of points}}' + 307 | '.'; 308 | 309 | it('should match zero', async () => { 310 | const msgFmt = await createComponent(msg, { locale: 'ar' }); 311 | const output = render(msgFmt, { numPeople: 0 }); 312 | expect(output).toBe('I have zero points.'); 313 | }); 314 | 315 | it('should match one', async () => { 316 | const msgFmt = await createComponent(msg, { locale: 'ar' }); 317 | const output = render(msgFmt, { numPeople: 1 }); 318 | expect(output).toBe('I have a point.'); 319 | }); 320 | 321 | it('should match two', async () => { 322 | const msgFmt = await createComponent(msg, { locale: 'ar' }); 323 | const output = render(msgFmt, { numPeople: 2 }); 324 | expect(output).toBe('I have two points.'); 325 | }); 326 | 327 | it('should match few', async () => { 328 | const msgFmt = await createComponent(msg, { locale: 'ar' }); 329 | const output = render(msgFmt, { numPeople: 5 }); 330 | expect(output).toBe('I have a few points.'); 331 | }); 332 | 333 | it('should match many', async () => { 334 | const msgFmt = await createComponent(msg, { locale: 'ar' }); 335 | const output = render(msgFmt, { numPeople: 20 }); 336 | expect(output).toBe('I have lots of points.'); 337 | }); 338 | 339 | it('should match other', async () => { 340 | const msgFmt = await createComponent(msg, { locale: 'ar' }); 341 | const output = render(msgFmt, { numPeople: 100 }); 342 | expect(output).toBe('I have some other amount of points.'); 343 | }); 344 | }); 345 | 346 | describe('with plural and select', () => { 347 | var simple = { 348 | en: '{NAME} went to {CITY}.', 349 | fr: 350 | '{NAME} est {GENDER, select, ' + 351 | 'female {allée}' + 352 | 'other {allé}}' + 353 | ' à {CITY}.', 354 | }; 355 | 356 | var complex = { 357 | en: '{TRAVELLERS} went to {CITY}.', 358 | 359 | fr: 360 | '{TRAVELLERS} {TRAVELLER_COUNT, plural, ' + 361 | '=1 {est {GENDER, select, ' + 362 | 'female {allée}' + 363 | 'other {allé}}}' + 364 | 'other {sont {GENDER, select, ' + 365 | 'female {allées}' + 366 | 'other {allés}}}}' + 367 | ' à {CITY}.', 368 | }; 369 | 370 | var maleObj = { 371 | NAME: 'Tony', 372 | CITY: 'Paris', 373 | GENDER: 'male', 374 | }; 375 | 376 | var femaleObj = { 377 | NAME: 'Jenny', 378 | CITY: 'Paris', 379 | GENDER: 'female', 380 | }; 381 | 382 | var maleTravelers = { 383 | TRAVELLERS: 'Lucas, Tony and Drew', 384 | TRAVELLER_COUNT: 3, 385 | GENDER: 'male', 386 | CITY: 'Paris', 387 | }; 388 | 389 | var femaleTravelers = { 390 | TRAVELLERS: 'Monica', 391 | TRAVELLER_COUNT: 1, 392 | GENDER: 'female', 393 | CITY: 'Paris', 394 | }; 395 | 396 | it('should format message en-US simple with different objects', async () => { 397 | const simpleEn = await createComponent(simple.en, { 398 | locale: 'en-US', 399 | }); 400 | expect(render(simpleEn, maleObj)).toBe('Tony went to Paris.'); 401 | expect(render(simpleEn, femaleObj)).toBe('Jenny went to Paris.'); 402 | }); 403 | 404 | it('should format message fr-FR simple with different objects', async () => { 405 | const simpleFr = await createComponent(simple.fr, { 406 | locale: 'fr-FR', 407 | }); 408 | expect(render(simpleFr, maleObj)).toBe('Tony est allé à Paris.'); 409 | expect(render(simpleFr, femaleObj)).toBe('Jenny est allée à Paris.'); 410 | }); 411 | 412 | it('should format message en-US complex with different objects', async () => { 413 | const complexEn = await createComponent(complex.en, { 414 | locale: 'en-US', 415 | }); 416 | expect(render(complexEn, maleTravelers)).toBe( 417 | 'Lucas, Tony and Drew went to Paris.' 418 | ); 419 | expect(render(complexEn, femaleTravelers)).toBe( 420 | 'Monica went to Paris.' 421 | ); 422 | }); 423 | 424 | it('should format message fr-FR complex with different objects', async () => { 425 | const complexFr = await createComponent(complex.fr, { 426 | locale: 'fr-FR', 427 | }); 428 | expect(render(complexFr, maleTravelers)).toBe( 429 | 'Lucas, Tony and Drew sont allés à Paris.' 430 | ); 431 | expect(render(complexFr, femaleTravelers)).toBe( 432 | 'Monica est allée à Paris.' 433 | ); 434 | }); 435 | }); 436 | 437 | describe('and change the locale with different counts', () => { 438 | const messages = { 439 | en: 440 | '{COMPANY_COUNT, plural, ' + 441 | '=1 {One company}' + 442 | 'other {# companies}}' + 443 | ' published new books.', 444 | 445 | ru: 446 | '{COMPANY_COUNT, plural, ' + 447 | '=1 {Одна компания опубликовала}' + 448 | 'one {# компания опубликовала}' + 449 | 'few {# компании опубликовали}' + 450 | 'many {# компаний опубликовали}' + 451 | 'other {# компаний опубликовали}}' + 452 | ' новые книги.', 453 | }; 454 | 455 | it('should format a message with en-US locale', async () => { 456 | const msgFmt = await createComponent(messages.en, { 457 | locale: 'en-US', 458 | }); 459 | expect(render(msgFmt, { COMPANY_COUNT: 0 })).toBe( 460 | '0 companies published new books.' 461 | ); 462 | expect(render(msgFmt, { COMPANY_COUNT: 1 })).toBe( 463 | 'One company published new books.' 464 | ); 465 | expect(render(msgFmt, { COMPANY_COUNT: 2 })).toBe( 466 | '2 companies published new books.' 467 | ); 468 | expect(render(msgFmt, { COMPANY_COUNT: 5 })).toBe( 469 | '5 companies published new books.' 470 | ); 471 | expect(render(msgFmt, { COMPANY_COUNT: 10 })).toBe( 472 | '10 companies published new books.' 473 | ); 474 | }); 475 | 476 | it('should format a message with ru-RU locale', async () => { 477 | const msgFmt = await createComponent(messages.ru, { 478 | locale: 'ru-RU', 479 | }); 480 | expect(render(msgFmt, { COMPANY_COUNT: 0 })).toBe( 481 | '0 компаний опубликовали новые книги.' 482 | ); 483 | expect(render(msgFmt, { COMPANY_COUNT: 1 })).toBe( 484 | 'Одна компания опубликовала новые книги.' 485 | ); 486 | expect(render(msgFmt, { COMPANY_COUNT: 2 })).toBe( 487 | '2 компании опубликовали новые книги.' 488 | ); 489 | expect(render(msgFmt, { COMPANY_COUNT: 5 })).toBe( 490 | '5 компаний опубликовали новые книги.' 491 | ); 492 | expect(render(msgFmt, { COMPANY_COUNT: 10 })).toBe( 493 | '10 компаний опубликовали новые книги.' 494 | ); 495 | expect(render(msgFmt, { COMPANY_COUNT: 21 })).toBe( 496 | '21 компания опубликовала новые книги.' 497 | ); 498 | }); 499 | }); 500 | 501 | describe('selectordinal arguments', () => { 502 | var msg = 503 | 'This is my {year, selectordinal, one{#st} two{#nd} few{#rd} other{#th}} birthday.'; 504 | 505 | it('should use ordinal pluralization rules', async () => { 506 | const msgFmt = await createComponent(msg, { locale: 'en' }); 507 | expect(render(msgFmt, { year: 1 })).toBe('This is my 1st birthday.'); 508 | expect(render(msgFmt, { year: 2 })).toBe('This is my 2nd birthday.'); 509 | expect(render(msgFmt, { year: 3 })).toBe('This is my 3rd birthday.'); 510 | expect(render(msgFmt, { year: 4 })).toBe('This is my 4th birthday.'); 511 | expect(render(msgFmt, { year: 11 })).toBe('This is my 11th birthday.'); 512 | expect(render(msgFmt, { year: 21 })).toBe('This is my 21st birthday.'); 513 | expect(render(msgFmt, { year: 22 })).toBe('This is my 22nd birthday.'); 514 | expect(render(msgFmt, { year: 33 })).toBe('This is my 33rd birthday.'); 515 | expect(render(msgFmt, { year: 44 })).toBe('This is my 44th birthday.'); 516 | expect(render(msgFmt, { year: 1024 })).toBe( 517 | 'This is my 1,024th birthday.' 518 | ); 519 | }); 520 | }); 521 | }); 522 | }); 523 | 524 | describe('string', () => { 525 | it('fails on invalid json', async () => { 526 | await expect( 527 | createComponents( 528 | { 529 | // @ts-ignore We want to test output on invalid input 530 | message: {}, 531 | }, 532 | { target: 'string' } 533 | ) 534 | ).rejects.toHaveProperty( 535 | 'message', 536 | 'Invalid JSON, "message" is not a string' 537 | ); 538 | }); 539 | 540 | it('creates empty component', async () => { 541 | const empty = await createTemplate(''); 542 | expect(empty()).toBe(''); 543 | }); 544 | 545 | it('creates simple text component', async () => { 546 | const simpleString = await createTemplate('x'); 547 | expect(simpleString()).toBe('x'); 548 | }); 549 | 550 | it('handles ICU arguments', async () => { 551 | const withArguments = await createTemplate('x {a} y {b} z'); 552 | const result = withArguments({ a: '1', b: '2' }); 553 | expect(result).toBe('x 1 y 2 z'); 554 | }); 555 | 556 | it('handles single argument only', async () => { 557 | const singleArg = await createTemplate('{a}'); 558 | const result = singleArg({ a: '1' }); 559 | expect(result).toBe('1'); 560 | }); 561 | 562 | it('handles twice defined ICU arguments', async () => { 563 | const argsTwice = await createTemplate('{a} {a}'); 564 | const result = argsTwice({ a: '1' }); 565 | expect(result).toBe('1 1'); 566 | }); 567 | 568 | it('handles numeric input concatenation correctly', async () => { 569 | const argsTwice = await createTemplate('{a}{b}'); 570 | const result = argsTwice({ a: 2, b: 3 }); 571 | expect(result).toBe('23'); 572 | }); 573 | 574 | it("Doesn't fail on React named component", async () => { 575 | const React = await createTemplate('react'); 576 | expect(React()).toBe('react'); 577 | }); 578 | 579 | it('do select expressions', async () => { 580 | const withSelect = await createTemplate( 581 | '{gender, select, male{He} female{She} other{They}}' 582 | ); 583 | const maleResult = withSelect({ 584 | gender: 'male', 585 | }); 586 | expect(maleResult).toBe('He'); 587 | const femaleResult = withSelect({ 588 | gender: 'female', 589 | }); 590 | expect(femaleResult).toBe('She'); 591 | const otherResult = withSelect({ 592 | gender: 'whatever', 593 | }); 594 | expect(otherResult).toBe('They'); 595 | }); 596 | 597 | it('can nest select expressions', async () => { 598 | const nestedSelect = await createTemplate(`a{x, select, 599 | a1 {b{y, select, 600 | a11 {g} 601 | a12 {h} 602 | other {} 603 | }d} 604 | a2 {c{z, select, 605 | a21 {i} 606 | a22 {j} 607 | other {} 608 | }e} 609 | other {} 610 | }f`); 611 | expect(nestedSelect({ x: 'a1', y: 'a11' })).toBe('abgdf'); 612 | expect(nestedSelect({ x: 'a1', y: 'a12' })).toBe('abhdf'); 613 | expect(nestedSelect({ x: 'a2', z: 'a21' })).toBe('acief'); 614 | expect(nestedSelect({ x: 'a2', z: 'a22' })).toBe('acjef'); 615 | }); 616 | 617 | it('can format numbers and dates', async () => { 618 | const msg = await createTemplate( 619 | 'At {theDate, time, medium} on {theDate, date, medium}, there was {text} on planet {planet, number, decimal}.' 620 | ); 621 | const result = msg({ 622 | theDate: new Date(1507216343344), 623 | text: 'a disturbance in the Force', 624 | planet: 7, 625 | }); 626 | expect(result).toBe( 627 | 'At 5:12:23 PM on Oct 5, 2017, there was a disturbance in the Force on planet 7.' 628 | ); 629 | }); 630 | 631 | it('can format dates from numbers', async () => { 632 | const msg = await createTemplate('On {theDate, date, medium}.'); 633 | const result = msg({ theDate: 1507216343344 }); 634 | expect(result).toBe('On Oct 5, 2017.'); 635 | }); 636 | 637 | it('makes string returning components for numbers, dates, times and pounds', async () => { 638 | const msg = await createTemplate( 639 | '{today, date}, {today, time}, {count, number}, {count, plural, other {#}}' 640 | ); 641 | 642 | const result = msg({ 643 | today: new Date(1507216343344), 644 | count: 123, 645 | }); 646 | 647 | expect(result).toBe('Oct 5, 2017, 5:12:23 PM, 123, 123'); 648 | }); 649 | 650 | it('handles number skeleton with goup-off', async () => { 651 | const msg = await createTemplate( 652 | '{amount, number, ::currency/CAD .0 group-off}', 653 | { locale: 'en-US' } 654 | ); 655 | 656 | const result = msg({ amount: 123456.78 }); 657 | expect(result).toBe('CA$123456.8'); 658 | }); 659 | 660 | it('handles number skeleton', async () => { 661 | const msg = await createTemplate('{amount, number, ::currency/GBP .0#}', { 662 | locale: 'en-US', 663 | }); 664 | 665 | const result = msg({ amount: 123456.789 }); 666 | expect(result).toBe('£123,456.79'); 667 | }); 668 | 669 | it('handles date skeleton', async () => { 670 | const msg = await createTemplate("{today, date, ::hh 'o''clock' a, zzzz}", { 671 | locale: 'en-US', 672 | }); 673 | 674 | const result = msg({ today: new Date(1579940163111) }); 675 | expect(result).toBe('09 AM Central European Standard Time'); 676 | }); 677 | 678 | it('custom formats should work for time', async () => { 679 | const msg = await createTemplate('Today is {time, time, verbose}', { 680 | formats: { 681 | time: { 682 | verbose: { 683 | month: 'long', 684 | day: 'numeric', 685 | year: 'numeric', 686 | hour: 'numeric', 687 | minute: 'numeric', 688 | second: 'numeric', 689 | timeZoneName: 'short', 690 | }, 691 | }, 692 | }, 693 | }); 694 | const result = msg({ time: new Date(0) }); 695 | expect(result).toBe('Today is January 1, 1970, 1:00:00 AM GMT+1'); 696 | }); 697 | 698 | it('can format percentages', async () => { 699 | const msg = await createTemplate('Score: {percentage, number, percent}.'); 700 | const result = msg({ 701 | percentage: 0.6549, 702 | }); 703 | expect(result).toBe('Score: 65%.'); 704 | expect(typeof msg({ percentage: 0.6549 })).toBe('string'); 705 | }); 706 | 707 | it('can reuse formatters', async () => { 708 | const spy = jest.spyOn(Intl, 'NumberFormat'); 709 | try { 710 | const msg = await createTemplate( 711 | 'Score: {score, number, percent}, Maximum: {max, number, percent}.', 712 | {}, 713 | Intl 714 | ); 715 | const result = msg({ 716 | score: 0.6549, 717 | max: 0.9436, 718 | }); 719 | expect(spy).toHaveBeenCalledTimes(1); 720 | expect(result).toBe('Score: 65%, Maximum: 94%.'); 721 | } finally { 722 | spy.mockRestore(); 723 | } 724 | }); 725 | 726 | it('can reuse formatted values', async () => { 727 | // TODO: find way to count number of .format calls 728 | const msg = await createTemplate( 729 | 'Score: {score, number, percent}, Maximum: {score, number, percent}.', 730 | {}, 731 | Intl 732 | ); 733 | const result = msg({ score: 0.6549 }); 734 | expect(result).toBe('Score: 65%, Maximum: 65%.'); 735 | }); 736 | 737 | it('can format currencies', async () => { 738 | const msg = await createTemplate('It costs {amount, number, USD}.', { 739 | locale: 'en-US', 740 | formats: { 741 | number: { 742 | USD: { 743 | style: 'currency', 744 | currency: 'USD', 745 | }, 746 | }, 747 | }, 748 | }); 749 | const result = msg({ amount: 123.456 }); 750 | expect(result).toBe('It costs $123.46.'); 751 | }); 752 | 753 | it('should handle @ correctly', async () => { 754 | const msg = await createTemplate('hi @{there}', { locale: 'en' }); 755 | expect(msg({ there: '2008' })).toBe('hi @2008'); 756 | }); 757 | 758 | describe('ported intl-messageformat tests', () => { 759 | describe('using a string pattern', () => { 760 | it('should properly replace direct arguments in the string', async () => { 761 | const mf = await createTemplate('My name is {FIRST} {LAST}.'); 762 | const output = mf({ FIRST: 'Anthony', LAST: 'Pipkin' }); 763 | expect(output).toBe('My name is Anthony Pipkin.'); 764 | }); 765 | 766 | it('should not ignore zero values', async () => { 767 | const mf = await createTemplate('I am {age} years old.'); 768 | const output = mf({ age: 0 }); 769 | expect(output).toBe('I am 0 years old.'); 770 | }); 771 | 772 | it('should stringify false, null, 0, and undefined', async () => { 773 | const mf = await createTemplate('{a} {b} {c} {d}'); 774 | const output = mf({ 775 | a: false, 776 | b: null, 777 | c: 0, 778 | d: undefined, 779 | }); 780 | expect(output).toBe('false null 0 undefined'); 781 | }); 782 | }); 783 | 784 | describe('and plurals under the Arabic locale', () => { 785 | const msg = 786 | '' + 787 | 'I have {numPeople, plural,' + 788 | 'zero {zero points}' + 789 | 'one {a point}' + 790 | 'two {two points}' + 791 | 'few {a few points}' + 792 | 'many {lots of points}' + 793 | 'other {some other amount of points}}' + 794 | '.'; 795 | 796 | it('should match zero', async () => { 797 | const msgFmt = await createTemplate(msg, { locale: 'ar' }); 798 | const output = msgFmt({ numPeople: 0 }); 799 | expect(output).toBe('I have zero points.'); 800 | }); 801 | 802 | it('should match one', async () => { 803 | const msgFmt = await createTemplate(msg, { locale: 'ar' }); 804 | const output = msgFmt({ numPeople: 1 }); 805 | expect(output).toBe('I have a point.'); 806 | }); 807 | 808 | it('should match two', async () => { 809 | const msgFmt = await createTemplate(msg, { locale: 'ar' }); 810 | const output = msgFmt({ numPeople: 2 }); 811 | expect(output).toBe('I have two points.'); 812 | }); 813 | 814 | it('should match few', async () => { 815 | const msgFmt = await createTemplate(msg, { locale: 'ar' }); 816 | const output = msgFmt({ numPeople: 5 }); 817 | expect(output).toBe('I have a few points.'); 818 | }); 819 | 820 | it('should match many', async () => { 821 | const msgFmt = await createTemplate(msg, { locale: 'ar' }); 822 | const output = msgFmt({ numPeople: 20 }); 823 | expect(output).toBe('I have lots of points.'); 824 | }); 825 | 826 | it('should match other', async () => { 827 | const msgFmt = await createTemplate(msg, { locale: 'ar' }); 828 | const output = msgFmt({ numPeople: 100 }); 829 | expect(output).toBe('I have some other amount of points.'); 830 | }); 831 | }); 832 | 833 | describe('with plural and select', () => { 834 | var simple = { 835 | en: '{NAME} went to {CITY}.', 836 | fr: 837 | '{NAME} est {GENDER, select, ' + 838 | 'female {allée}' + 839 | 'other {allé}}' + 840 | ' à {CITY}.', 841 | }; 842 | 843 | var complex = { 844 | en: '{TRAVELLERS} went to {CITY}.', 845 | 846 | fr: 847 | '{TRAVELLERS} {TRAVELLER_COUNT, plural, ' + 848 | '=1 {est {GENDER, select, ' + 849 | 'female {allée}' + 850 | 'other {allé}}}' + 851 | 'other {sont {GENDER, select, ' + 852 | 'female {allées}' + 853 | 'other {allés}}}}' + 854 | ' à {CITY}.', 855 | }; 856 | 857 | var maleObj = { 858 | NAME: 'Tony', 859 | CITY: 'Paris', 860 | GENDER: 'male', 861 | }; 862 | 863 | var femaleObj = { 864 | NAME: 'Jenny', 865 | CITY: 'Paris', 866 | GENDER: 'female', 867 | }; 868 | 869 | var maleTravelers = { 870 | TRAVELLERS: 'Lucas, Tony and Drew', 871 | TRAVELLER_COUNT: 3, 872 | GENDER: 'male', 873 | CITY: 'Paris', 874 | }; 875 | 876 | var femaleTravelers = { 877 | TRAVELLERS: 'Monica', 878 | TRAVELLER_COUNT: 1, 879 | GENDER: 'female', 880 | CITY: 'Paris', 881 | }; 882 | 883 | it('should format message en-US simple with different objects', async () => { 884 | const simpleEn = await createTemplate(simple.en, { 885 | locale: 'en-US', 886 | }); 887 | expect(simpleEn(maleObj)).toBe('Tony went to Paris.'); 888 | expect(simpleEn(femaleObj)).toBe('Jenny went to Paris.'); 889 | }); 890 | 891 | it('should format message fr-FR simple with different objects', async () => { 892 | const simpleFr = await createTemplate(simple.fr, { 893 | locale: 'fr-FR', 894 | }); 895 | expect(simpleFr(maleObj)).toBe('Tony est allé à Paris.'); 896 | expect(simpleFr(femaleObj)).toBe('Jenny est allée à Paris.'); 897 | }); 898 | 899 | it('should format message en-US complex with different objects', async () => { 900 | const complexEn = await createTemplate(complex.en, { 901 | locale: 'en-US', 902 | }); 903 | expect(complexEn(maleTravelers)).toBe( 904 | 'Lucas, Tony and Drew went to Paris.' 905 | ); 906 | expect(complexEn(femaleTravelers)).toBe('Monica went to Paris.'); 907 | }); 908 | 909 | it('should format message fr-FR complex with different objects', async () => { 910 | const complexFr = await createTemplate(complex.fr, { 911 | locale: 'fr-FR', 912 | }); 913 | expect(complexFr(maleTravelers)).toBe( 914 | 'Lucas, Tony and Drew sont allés à Paris.' 915 | ); 916 | expect(complexFr(femaleTravelers)).toBe('Monica est allée à Paris.'); 917 | }); 918 | }); 919 | 920 | describe('and change the locale with different counts', () => { 921 | const messages = { 922 | en: 923 | '{COMPANY_COUNT, plural, ' + 924 | '=1 {One company}' + 925 | 'other {# companies}}' + 926 | ' published new books.', 927 | 928 | ru: 929 | '{COMPANY_COUNT, plural, ' + 930 | '=1 {Одна компания опубликовала}' + 931 | 'one {# компания опубликовала}' + 932 | 'few {# компании опубликовали}' + 933 | 'many {# компаний опубликовали}' + 934 | 'other {# компаний опубликовали}}' + 935 | ' новые книги.', 936 | }; 937 | 938 | it('should format a message with en-US locale', async () => { 939 | const msgFmt = await createTemplate(messages.en, { 940 | locale: 'en-US', 941 | }); 942 | expect(msgFmt({ COMPANY_COUNT: 0 })).toBe( 943 | '0 companies published new books.' 944 | ); 945 | expect(msgFmt({ COMPANY_COUNT: 1 })).toBe( 946 | 'One company published new books.' 947 | ); 948 | expect(msgFmt({ COMPANY_COUNT: 2 })).toBe( 949 | '2 companies published new books.' 950 | ); 951 | expect(msgFmt({ COMPANY_COUNT: 5 })).toBe( 952 | '5 companies published new books.' 953 | ); 954 | expect(msgFmt({ COMPANY_COUNT: 10 })).toBe( 955 | '10 companies published new books.' 956 | ); 957 | }); 958 | 959 | it('should format a message with ru-RU locale', async () => { 960 | const msgFmt = await createTemplate(messages.ru, { 961 | locale: 'ru-RU', 962 | }); 963 | expect(msgFmt({ COMPANY_COUNT: 0 })).toBe( 964 | '0 компаний опубликовали новые книги.' 965 | ); 966 | expect(msgFmt({ COMPANY_COUNT: 1 })).toBe( 967 | 'Одна компания опубликовала новые книги.' 968 | ); 969 | expect(msgFmt({ COMPANY_COUNT: 2 })).toBe( 970 | '2 компании опубликовали новые книги.' 971 | ); 972 | expect(msgFmt({ COMPANY_COUNT: 5 })).toBe( 973 | '5 компаний опубликовали новые книги.' 974 | ); 975 | expect(msgFmt({ COMPANY_COUNT: 10 })).toBe( 976 | '10 компаний опубликовали новые книги.' 977 | ); 978 | expect(msgFmt({ COMPANY_COUNT: 21 })).toBe( 979 | '21 компания опубликовала новые книги.' 980 | ); 981 | }); 982 | }); 983 | 984 | describe('selectordinal arguments', () => { 985 | var msg = 986 | 'This is my {year, selectordinal, one{#st} two{#nd} few{#rd} other{#th}} birthday.'; 987 | 988 | it('should use ordinal pluralization rules', async () => { 989 | const msgFmt = await createTemplate(msg, { locale: 'en' }); 990 | expect(msgFmt({ year: 1 })).toBe('This is my 1st birthday.'); 991 | expect(msgFmt({ year: 2 })).toBe('This is my 2nd birthday.'); 992 | expect(msgFmt({ year: 3 })).toBe('This is my 3rd birthday.'); 993 | expect(msgFmt({ year: 4 })).toBe('This is my 4th birthday.'); 994 | expect(msgFmt({ year: 11 })).toBe('This is my 11th birthday.'); 995 | expect(msgFmt({ year: 21 })).toBe('This is my 21st birthday.'); 996 | expect(msgFmt({ year: 22 })).toBe('This is my 22nd birthday.'); 997 | expect(msgFmt({ year: 33 })).toBe('This is my 33rd birthday.'); 998 | expect(msgFmt({ year: 44 })).toBe('This is my 44th birthday.'); 999 | expect(msgFmt({ year: 1024 })).toBe('This is my 1,024th birthday.'); 1000 | }); 1001 | }); 1002 | }); 1003 | }); 1004 | 1005 | function errorSnapshotTest(message: string) { 1006 | it('error snapshot', async () => { 1007 | expect.assertions(1); 1008 | try { 1009 | await createComponent(message); 1010 | } catch (err) { 1011 | const formatted = formatError(message, err); 1012 | expect(formatted).toMatchSnapshot(); 1013 | } 1014 | }); 1015 | } 1016 | 1017 | describe('error formatting', () => { 1018 | errorSnapshotTest('unclosed {argument message'); 1019 | errorSnapshotTest('foo'); 1020 | errorSnapshotTest('foo'); 1021 | errorSnapshotTest(` 1022 | {gender, select, 1023 | male {He} 1024 | } 1025 | `); 1026 | errorSnapshotTest('<>foo {bar} baz'); 1027 | errorSnapshotTest(''); 1028 | }); 1029 | 1030 | describe('with jsx', () => { 1031 | it('understands jsx', async () => { 1032 | const withJsx = await createComponent('foo'); 1033 | // TODO: will this be supported? 1034 | // const result1 = renderReact(withJsx, {}); 1035 | // expect(result1).toBe('foo'); 1036 | const result2 = render(withJsx, { 1037 | A: ({ children }: React.PropsWithChildren<{}>) => 1038 | React.createElement('span', { className: 'bar' }, children), 1039 | }); 1040 | expect(result2).toBe('foo'); 1041 | }); 1042 | 1043 | it('understands jsx with argument', async () => { 1044 | const withArgJsx = await createComponent('foo {bar} baz'); 1045 | // TODO: will this be supported? 1046 | // const result1 = render(withArgJsx, { bar: 'quux' }); 1047 | // expect(result1).toBe('foo quux baz'); 1048 | const result2 = render(withArgJsx, { 1049 | A: ({ children }: React.PropsWithChildren<{}>) => 1050 | React.createElement('span', { className: 'bla' }, children), 1051 | bar: 'quux', 1052 | }); 1053 | expect(result2).toBe('foo quux baz'); 1054 | }); 1055 | 1056 | it('handles special characters', async () => { 1057 | const htmlSpecialChars = await createComponent('Hel\'lo Wo"rld!'); 1058 | const result = render(htmlSpecialChars); 1059 | expect(result).toBe('Hel'lo Wo"rld!'); 1060 | }); 1061 | 1062 | it('can interpolate arrays', async () => { 1063 | const interpolate = await createComponent('a {b} c'); 1064 | const result = render(interpolate, { 1065 | b: ['x', 'y', 'z'], 1066 | }); 1067 | expect(result).toBe('a xyz c'); 1068 | }); 1069 | 1070 | it('understands component named "React"', async () => { 1071 | const components = await createComponents( 1072 | { React: 'foo bar' }, 1073 | { target: 'react' } 1074 | ); 1075 | expect(components.React).toHaveProperty('displayName', 'React'); 1076 | const result = render(components.React, { 1077 | A: ({ children }: React.PropsWithChildren<{}>) => 1078 | React.createElement('span', null, children), 1079 | }); 1080 | expect(result).toBe('foo bar'); 1081 | }); 1082 | 1083 | it('ignores jsx when react is disabled', async () => { 1084 | const { React } = await createComponents( 1085 | { React: 'foo bar' }, 1086 | { target: 'string' } 1087 | ); 1088 | const result = React({ 1089 | A: (children: string) => `_${children}_`, 1090 | }); 1091 | expect(typeof result).toBe('string'); 1092 | expect(result).toBe('foo _bar_'); 1093 | }); 1094 | 1095 | it('can interpolate "React"', async () => { 1096 | const withReact = await createComponent('foo {React} baz'); 1097 | const result = render(withReact, { React: 'bar', A: () => null }); 1098 | expect(result).toBe('foo bar baz'); 1099 | }); 1100 | 1101 | it('can interpolate React elements', async () => { 1102 | const withReact = await createComponent('foo {elm} bar'); 1103 | const result = render(withReact, { 1104 | elm: React.createElement('span', null, 'baz'), 1105 | }); 1106 | expect(result).toBe('foo baz bar'); 1107 | }); 1108 | 1109 | it('understands nested jsx', async () => { 1110 | const withNestedJsx = await createComponent('foo bar baz'); 1111 | const result1 = render(withNestedJsx, { 1112 | A: ({ children }: React.PropsWithChildren<{}>) => children, 1113 | B: ({ children }: React.PropsWithChildren<{}>) => children, 1114 | }); 1115 | expect(result1).toBe('foo bar baz'); 1116 | }); 1117 | }); 1118 | -------------------------------------------------------------------------------- /packages/nymus/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as t from '@babel/types'; 2 | import * as babel from '@babel/core'; 3 | import { Formats } from './formats'; 4 | import { codeFrameColumns, BabelCodeFrameOptions } from '@babel/code-frame'; 5 | import Module, { ModuleTarget } from './Module'; 6 | import TsPlugin from '@babel/plugin-transform-typescript'; 7 | 8 | interface Messages { 9 | [key: string]: string; 10 | } 11 | 12 | export interface CreateModuleOptions { 13 | locale?: string; 14 | formats?: Partial; 15 | ast?: boolean; 16 | target?: ModuleTarget; 17 | react?: boolean; 18 | typescript?: boolean; 19 | declarations?: boolean; 20 | } 21 | 22 | interface Location { 23 | start: Position; 24 | end: Position; 25 | } 26 | 27 | interface Position { 28 | offset: number; 29 | line: number; 30 | column: number; 31 | } 32 | 33 | export function formatError( 34 | input: string, 35 | err: Error & { location?: Location; loc?: Position }, 36 | options: Omit = {} 37 | ): string { 38 | const location = 39 | err.location || (err.loc && { start: err.loc, end: err.loc }); 40 | if (!location) { 41 | return err.message; 42 | } 43 | return codeFrameColumns(input, location, { 44 | ...options, 45 | message: err.message, 46 | }); 47 | } 48 | 49 | export async function createModuleAst( 50 | messages: Messages, 51 | options: CreateModuleOptions = {} 52 | ): Promise { 53 | const module = new Module(options); 54 | 55 | for (const [key, message] of Object.entries(messages)) { 56 | if (typeof message !== 'string') { 57 | throw new Error(`Invalid JSON, "${key}" is not a string`); 58 | } 59 | const componentName = t.toIdentifier(key); 60 | module.addMessage(componentName, message); 61 | } 62 | 63 | return t.program(module.buildModuleAst()); 64 | } 65 | 66 | export default async function createModule( 67 | messages: Messages, 68 | options: CreateModuleOptions = {} 69 | ) { 70 | const tsAst = await createModuleAst(messages, options); 71 | 72 | let declarations: string | undefined; 73 | 74 | if (!options.typescript && options.declarations) { 75 | const { code } = babel.transformFromAstSync(tsAst) || {}; 76 | if (!code) { 77 | throw new Error('Failed to generate code'); 78 | } 79 | 80 | const ts = await import('typescript'); 81 | 82 | const host = ts.createCompilerHost({}); 83 | 84 | const readFile = host.readFile; 85 | host.readFile = (filename: string) => { 86 | return filename === 'messages.ts' ? code : readFile(filename); 87 | }; 88 | 89 | host.writeFile = (fileName: string, contents: string) => { 90 | declarations = contents; 91 | }; 92 | 93 | const program = ts.createProgram( 94 | ['messages.ts'], 95 | { 96 | noResolve: true, 97 | types: [], 98 | emitDeclarationOnly: true, 99 | declaration: true, 100 | }, 101 | host 102 | ); 103 | program.emit(); 104 | } 105 | 106 | const { code, ast } = 107 | (await babel.transformFromAstAsync(tsAst, undefined, { 108 | ast: options.ast, 109 | plugins: [...(options.typescript ? [] : [TsPlugin])], 110 | })) || {}; 111 | 112 | if (!code) { 113 | throw new Error('Failed to generate code'); 114 | } 115 | 116 | return { 117 | code, 118 | ast, 119 | declarations, 120 | }; 121 | } 122 | -------------------------------------------------------------------------------- /packages/nymus/src/testUtils.ts: -------------------------------------------------------------------------------- 1 | import createModule, { CreateModuleOptions } from './index'; 2 | import * as vm from 'vm'; 3 | import * as babelCore from '@babel/core'; 4 | import * as React from 'react'; 5 | import * as ReactDOMServer from 'react-dom/server'; 6 | 7 | function importFrom( 8 | code: string, 9 | options: CreateModuleOptions, 10 | intlMock = Intl 11 | ) { 12 | const { code: cjs } = 13 | babelCore.transformSync(code, { 14 | presets: ['@babel/preset-react', '@babel/preset-env'], 15 | }) || {}; 16 | 17 | if (!cjs) { 18 | throw new Error(`Compilation result is empty for "${code}"`); 19 | } 20 | 21 | const exports = {}; 22 | const requireFn = (moduleId: string) => { 23 | if (moduleId === 'react' && options.target !== 'react') { 24 | throw new Error('importing react is not allowed'); 25 | } 26 | return require(moduleId); 27 | }; 28 | vm.runInThisContext(` 29 | (require, exports, Intl) => { 30 | ${cjs} 31 | } 32 | `)(requireFn, exports, intlMock); 33 | return exports; 34 | } 35 | 36 | interface Messages { 37 | [key: string]: string; 38 | } 39 | 40 | type ComponentsOf = { 41 | [K in keyof T]: C; 42 | }; 43 | 44 | export async function createComponents( 45 | messages: T, 46 | options?: CreateModuleOptions & { target?: 'react' }, 47 | intlMock?: typeof Intl 48 | ): Promise>>; 49 | export async function createComponents( 50 | messages: T, 51 | options: CreateModuleOptions & { target: 'string' }, 52 | intlMock?: typeof Intl 53 | ): Promise string>>; 54 | export async function createComponents( 55 | messages: T, 56 | options: CreateModuleOptions = {}, 57 | intlMock: typeof Intl = Intl 58 | ): Promise> { 59 | const { code } = await createModule(messages, options); 60 | // console.log(code); 61 | const components = importFrom(code, options, intlMock) as ComponentsOf; 62 | return components; 63 | } 64 | 65 | export async function createComponent( 66 | message: string, 67 | options?: CreateModuleOptions, 68 | intlMock: typeof Intl = Intl 69 | ): Promise> { 70 | const { Component } = await createComponents<{ Component: string }>( 71 | { Component: message }, 72 | { ...options, target: 'react' }, 73 | intlMock 74 | ); 75 | return Component; 76 | } 77 | 78 | export async function createTemplate( 79 | message: string, 80 | options?: CreateModuleOptions, 81 | intlMock: typeof Intl = Intl 82 | ): Promise<(props?: any) => string> { 83 | const { Component } = await createComponents<{ Component: string }>( 84 | { Component: message }, 85 | { ...options, target: 'string' }, 86 | intlMock 87 | ); 88 | return Component; 89 | } 90 | 91 | export function render(elm: React.FunctionComponent, props = {}) { 92 | return ReactDOMServer.renderToStaticMarkup(React.createElement(elm, props)); 93 | } 94 | -------------------------------------------------------------------------------- /packages/nymus/src/types/babel__plugin-transform-typescript.ts: -------------------------------------------------------------------------------- 1 | declare module '@babel/plugin-transform-typescript' { 2 | import { PluginItem } from '@babel/core'; 3 | const plugin: PluginItem; 4 | export default plugin; 5 | } 6 | -------------------------------------------------------------------------------- /packages/nymus/src/webpack.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import path from 'path'; 4 | import webpack from 'webpack'; 5 | import { createFsFromVolume, Volume } from 'memfs'; 6 | import { fileExists, tmpDirFromTemplate } from './fileUtil'; 7 | import { join as pathJoin } from 'path'; 8 | import * as fs from 'fs'; 9 | import { promisify } from 'util'; 10 | 11 | const fsReadFile = promisify(fs.readFile); 12 | 13 | async function compile(context, fixture, options = {}): Promise { 14 | const compiler = webpack({ 15 | context, 16 | entry: fixture, 17 | output: { 18 | path: path.resolve(__dirname), 19 | filename: 'bundle.js', 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.json$/, 25 | type: 'javascript/auto', 26 | use: { 27 | loader: path.resolve(__dirname, '../webpack.js'), 28 | options, 29 | }, 30 | }, 31 | ], 32 | }, 33 | }); 34 | 35 | compiler.outputFileSystem = Object.assign(createFsFromVolume(new Volume()), { 36 | join: pathJoin, 37 | }); 38 | 39 | return new Promise((resolve, reject) => { 40 | compiler.run((err, stats) => { 41 | if (err) reject(err); 42 | if (stats.hasErrors()) reject(new Error(stats.toJson().errors[0])); 43 | resolve(stats); 44 | }); 45 | }); 46 | } 47 | 48 | describe('webpack', () => { 49 | let fixtureDir; 50 | 51 | function fixturePath(src: string) { 52 | return path.resolve(fixtureDir.path, src); 53 | } 54 | 55 | beforeEach(async () => { 56 | fixtureDir = await tmpDirFromTemplate( 57 | path.resolve(__dirname, './__fixtures__') 58 | ); 59 | }); 60 | 61 | afterEach(() => { 62 | fixtureDir.cleanup(); 63 | }); 64 | 65 | it('should compile', async () => { 66 | const stats = await compile(fixtureDir.path, './strings/en.json'); 67 | const statsJson = stats.toJson(); 68 | expect(statsJson.modules[0].source).toMatchInlineSnapshot(` 69 | "function message() { 70 | return \\"Hello world\\"; 71 | } 72 | 73 | export { message };" 74 | `); 75 | expect(await fileExists(fixturePath('./strings/en.json.d.ts'))).toBe(false); 76 | }); 77 | 78 | it('should emit declarations', async () => { 79 | await compile(fixtureDir.path, './strings/en.json', { declarations: true }); 80 | expect( 81 | await fsReadFile(fixturePath('./strings/en.json.d.ts'), { 82 | encoding: 'utf-8', 83 | }) 84 | ).toMatchInlineSnapshot(` 85 | "declare function message(): string; 86 | export { message }; 87 | " 88 | `); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /packages/nymus/src/webpack.ts: -------------------------------------------------------------------------------- 1 | import createModule from './index'; 2 | import { loader } from 'webpack'; 3 | import * as loaderUtils from 'loader-utils'; 4 | import * as fs from 'fs'; 5 | import { promisify } from 'util'; 6 | 7 | const fsWriteFile = promisify(fs.writeFile); 8 | 9 | const icuLoader: loader.Loader = function icuLoader(source) { 10 | const options = loaderUtils.getOptions(this); 11 | const callback = this.async(); 12 | const messages = JSON.parse(String(source)); 13 | createModule(messages, { ...options, target: 'react' }) 14 | .then(async ({ code, declarations }) => { 15 | if (options.declarations && declarations) { 16 | const declarationsPath = this.resourcePath + '.d.ts'; 17 | await fsWriteFile(declarationsPath, declarations, { 18 | encoding: 'utf-8', 19 | }); 20 | } 21 | callback!(null, code); 22 | }) 23 | .catch(callback); 24 | }; 25 | 26 | export default icuLoader; 27 | -------------------------------------------------------------------------------- /packages/nymus/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["**/*.test.ts"], 4 | "extends": "../../tsconfig.json", 5 | "compilerOptions": { 6 | "target": "es5", 7 | "lib": ["es2015"], 8 | "module": "commonjs", 9 | "outDir": "dist", 10 | "rootDir": "src", 11 | "declaration": true, 12 | "typeRoots": ["src/types"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/nymus/webpack.d.ts: -------------------------------------------------------------------------------- 1 | export { default } from './dist/webpack'; 2 | -------------------------------------------------------------------------------- /packages/nymus/webpack.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist/webpack'); 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "checkJs": false, 5 | "downlevelIteration": true, 6 | "strict": true, 7 | "noUnusedLocals": true, 8 | "noImplicitReturns": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "forceConsistentCasingInFileNames": true 11 | } 12 | } 13 | --------------------------------------------------------------------------------