(
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 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
75 |
76 |
77 |
112 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
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 |
--------------------------------------------------------------------------------