├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .travis.yml ├── README.md ├── bin ├── c-3po └── ttag ├── package-lock.json ├── package.json ├── scripts ├── doc.js ├── install_completion.js └── ttag ├── src ├── commands │ ├── check.ts │ ├── color.ts │ ├── extract.ts │ ├── filter.ts │ ├── init.ts │ ├── merge.ts │ ├── po2json.ts │ ├── pseudo.ts │ ├── replace.ts │ ├── spell.ts │ ├── stats.ts │ ├── translate.ts │ ├── update.ts │ ├── validate.ts │ └── web.ts ├── declarations.ts ├── defaults.ts ├── index.ts ├── lib │ ├── browser.ts │ ├── checkDuplicateKeys.ts │ ├── extract.ts │ ├── merge.ts │ ├── parser.ts │ ├── pathsWalk.ts │ ├── print.ts │ ├── serializer.ts │ ├── spell.ts │ ├── ttagPluginOverride.ts │ ├── ttagRcOverride.ts │ ├── update.ts │ ├── utils.ts │ └── validation.ts └── types.ts ├── tests ├── commands │ ├── __snapshots__ │ │ ├── test_check.ts.snap │ │ ├── test_color.ts.snap │ │ ├── test_extract.ts.snap │ │ ├── test_init.ts.snap │ │ ├── test_merge.ts.snap │ │ ├── test_po2js.ts.snap │ │ ├── test_replace.ts.snap │ │ ├── test_spell.ts.snap │ │ ├── test_stats.ts.snap │ │ ├── test_translate.ts.snap │ │ └── test_update.ts.snap │ ├── test_check.ts │ ├── test_color.ts │ ├── test_extract.ts │ ├── test_filter.ts │ ├── test_init.ts │ ├── test_merge.ts │ ├── test_po2js.ts │ ├── test_replace.ts │ ├── test_spell.ts │ ├── test_stats.ts │ ├── test_translate.ts │ ├── test_update.ts │ └── test_validate.ts ├── fixtures │ ├── baseTest │ │ ├── nested │ │ │ └── test2.js │ │ ├── test.xml │ │ └── test1.js │ ├── checkTest │ │ ├── check-invalid-format-discover.js │ │ ├── check-same-key.js │ │ ├── check-trans-exist.js │ │ ├── check-trans-invalid-format.js │ │ ├── check-trans-not-exist.js │ │ ├── check.po │ │ ├── same_key.po │ │ └── same_key_update.po │ ├── colorTest │ │ └── color.po │ ├── filterTest │ │ └── filter.po │ ├── globalFunc.js │ ├── mergeTest │ │ ├── merge-with-encoding.po │ │ ├── mergeLeft.po │ │ ├── mergeRight.po │ │ └── mergeRightRight.po │ ├── po2jsTest │ │ └── po2js.po │ ├── replaceTest │ │ ├── nested │ │ │ ├── global.js │ │ │ └── test2.js │ │ ├── test.js │ │ └── translations.po │ ├── sortByMsgidTest │ │ └── test.js │ ├── spellTest │ │ └── spell.po │ ├── statsTest │ │ └── stats.po │ ├── tSParse.ts │ ├── tSXParse.tsx │ ├── testJSXParse.jsx │ ├── testSvelteParse.svelte │ ├── translateTest │ │ └── translate.po │ ├── tsConstEnum.ts │ ├── tsNullishCoalescing.ts │ ├── tsOptionalChaning.ts │ ├── ukLocaleTest │ │ └── index.js │ ├── updateTest │ │ ├── comments.jsx │ │ ├── context.jsx │ │ ├── hoisting.js │ │ └── test.js │ ├── validateTest │ │ └── validate.po │ └── vueTest │ │ ├── testVueParse.vue │ │ └── testVueWithTagInScript.vue └── lib │ ├── test_merge.ts │ ├── test_update.ts │ └── test_utils.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | *.log 4 | translations.pot 5 | .vscode/ 6 | *.swp 7 | .DS_Store 8 | .idea -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests/ 2 | src/ 3 | dist/tests 4 | .idea 5 | !dist/src -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | tests/fixtures/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "tabWidth": 4 4 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 10 5 | - "11.10.1" 6 | 7 | before_script: 8 | - npm i 9 | 10 | script: 11 | - npm run build 12 | - npm test -- --ci 13 | 14 | deploy: 15 | provider: npm 16 | email: alexmost1989@gmail.com 17 | skip_cleanup: true 18 | api_key: 19 | secure: OiFF9dgNbpRT6oTKAkOjhGGj9mokC73xj24Xf6e3SIbBccbhyqbe5nm6S4t7TrMQHzmFkYghWBxraFtVY30cjCmLeTSXkJ+hR1zpG/TikSylBdUD6bR+AsjxrrbeEXH0c6qmPq654D1+W6WWS1fq9Y9CrLgeVwMCpFwP0zvnCZ+Om8WknFydeYRnwn05OjG7Zp5zlkp8TkynALkIuxl4zLFmmGGfC/CQq7Pc9YJOVgp6VZ/ReBX4baneJVzcJFVeka30fqHiYGlP9Wt+U3burkdEUOX3PxtP4jctMGkKN2kAAh05gG0Ef0wvYSJ0R1ErzYBLvf3/Y61H4QObZdCZj4+I/hbe8owEeDzNs5U+44Z2lyea6LjjPR7R09Lx7iyjltcZb275OkVBoWqltKYZwTrSSzIjXEgCfMYoiYu1hJZM/mKs11MRuLlB3IHq/gJh3OlySmiP5NQ34Rs5YQX04aAgfPZB9q4JFTx890jJJe9fk/1jFljJBrbc4F0CWOioIybRBjMFwhWMXlVrxPkh9q6rozoiKk2GyOnZVFZyz7mE7XQh+/shJ4nddAsg18i6B1e0iWqBLgn5UUjlFliMqZz00+/AsL/1jNUlqrDdb2jArMq8k9GcCc3ZfY6Z65K0V9DVf2euUU/hrRMJMja5Wsc4XJF8Oci3Dhv173ISmQE= 20 | on: 21 | tags: true 22 | branch: master 23 | repo: ttag-org/ttag-cli 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://stand-with-ukraine.pp.ua) 2 | 3 | # ttag-cli 4 | 5 | > :warning: This project [was previously named `c-3po-cli`](https://github.com/ttag-org/ttag/issues/105). 6 | > Some of the talks, presentations, and documentation _may_ reference it with both names. 7 | 8 | Command line utility for [ttag](https://github.com/ttag-org/ttag) translation library. 9 | Works out of the box with **js**, **ts**, **jsx**, **tsx**, **mjs**, **cjs**, **vue**, **svelte**, files. 10 | 11 | # Installation 12 | ```bash 13 | npm install ttag-cli 14 | # or global 15 | npm install -g ttag-cli 16 | ``` 17 | 18 | # Usage example: 19 | ``` 20 | ttag extract some.js 21 | ``` 22 | 23 | # Commands description 24 | 25 | 26 | ### `extract [output|lang] ` 27 | will extract translations to .pot file 28 | #### Arguments: 29 | --output -o result file with translations (.pot) (default: translations.pot) 30 | --lang -l sets default lang (ISO format) (default: en) 31 | --discover string overrides babel-plugi-ttag setting - https://ttag.js.org/docs/plugin-api.html#configdiscover. Can be used to discover ttag functions without explicit import. Only known ttag functions can be used as params (t, jt, ngettext, gettext, _) 32 | --numberedExpressions boolean overrides babel-plugin-ttag setting - https://ttag.js.org/docs/plugin-api.html#confignumberedexpressions. Refer to the doc for the details. 33 | --extractLocation string - 'full' | 'file' | 'never' - https://ttag.js.org/docs/plugin-api.html#configextractlocation. Is used to format location comments in the .po file. 34 | --sortByMsgid boolean. Will sort output in alphabetically by msgid. https://ttag.js.org/docs/plugin-api.html#configsortbymsgid 35 | 36 | 37 | ### `check [lang] ` 38 | will check if all translations are present in .po file 39 | #### Arguments: 40 | --lang -l sets default lang (ISO format) (default: en) 41 | --skip allows you to skip some instances of validation. This can be useful when you want to check your .po file for duplicate keys 42 | [choices: "translation"] 43 | --discover string overrides babel-plugi-ttag setting - https://ttag.js.org/docs/plugin-api.html#configdiscover. Can be used to discover ttag functions without explicit import. Only known ttag functions can be used as params (t, jt, ngettext, gettext, _) 44 | --numberedExpressions boolean overrides babel-plugin-ttag setting - https://ttag.js.org/docs/plugin-api.html#confignumberedexpressions. Refer to the doc for the details. 45 | 46 | 47 | ### `merge ` 48 | will merge two or more po(t) files together using first non-empty msgstr and header from left-most file 49 | 50 | 51 | ### `translate [args]` 52 | will open interactive prompt to translate all msgids with empty msgstr in cli 53 | #### Arguments: 54 | --output -o result file with translations (.po) (default: translated.po) 55 | 56 | 57 | ### `stats ` 58 | will display various pofile statistics(encoding, plurals, translated, fuzzyness) 59 | 60 | 61 | ### `filter [args]` 62 | will filter pofile by entry attributes(fuzzy, obsolete, (un)translated) 63 | #### Arguments: 64 | --fuzzy -f result file with fuzzy messages (.po) (default: false) 65 | --no-fuzzy -nf result file without fuzzy messages (.po) (default: false) 66 | --translated -t result file with translations (.po) (default: false) 67 | --not-translated -nt result file without translations (.po) (default: false) 68 | --reference -r a regexp to match references against (default: ) 69 | #### Example: 70 | ttag filter -nt small.po 71 | 72 | msgid "test" 73 | msgstr "" 74 | 75 | ### `init ` 76 | will create an empty .po file with all necessary headers for the locale 77 | #### Arguments: 78 | --lang sets default locale (ISO format) (default: en) 79 | --filename path to the .po file 80 | 81 | 82 | ### `update [opts] ` 83 | will update existing po file. Add/remove new translations 84 | #### Arguments: 85 | --lang sets default locale (ISO format) (default: en) 86 | --pofile path to .po file with translations 87 | --src path to source files/directories 88 | --discover string overrides babel-plugi-ttag setting - https://ttag.js.org/docs/plugin-api.html#configdiscover. Can be used to discover ttag functions without explicit import. Only known ttag functions can be used as params (t, jt, ngettext, gettext, _) 89 | --numberedExpressions boolean overrides babel-plugin-ttag setting - https://ttag.js.org/docs/plugin-api.html#confignumberedexpressions. Refer to the doc for the details. 90 | --extractLocation string - 'full' | 'file' | 'never' - https://ttag.js.org/docs/plugin-api.html#configextractlocation. Is used to format location comments in the .po file. 91 | --sortByMsgid boolean. Will sort output in alphabetically by msgid. https://ttag.js.org/docs/plugin-api.html#configsortbymsgid 92 | --foldLength number. Output .po file line width. 93 | 94 | ### `replace [options] ` 95 | will replace all strings with translations from the .po file 96 | #### Arguments: 97 | --discover string overrides babel-plugi-ttag setting - https://ttag.js.org/docs/plugin-api.html#configdiscover. Can be used to discover ttag functions without explicit import. Only known ttag functions can be used as params (t, jt, ngettext, gettext, _) 98 | --numberedExpressions boolean overrides babel-plugin-ttag setting - https://ttag.js.org/docs/plugin-api.html#confignumberedexpressions. Refer to the doc for the details. 99 | 100 | 101 | ### `color ` 102 | will output po(t)file with pretty colors on, combine with | less -r 103 | 104 | 105 | ### `spell [locale]` 106 | will spellcheck po file messages with given locale, locale can be autodetected from pofile 107 | 108 | 109 | ### `validate ` 110 | will validate js template strings (`${x}`) in messages and translations and against each other 111 | 112 | 113 | ### `web ` 114 | will open pofile in web editor 115 | 116 | 117 | ### `po2json [args]` 118 | will parse and output po file as loadable JSON 119 | #### Arguments: 120 | --pretty -p pretty print js (default: false) 121 | --nostrip --n do not strip comments/headers (default: false) 122 | --format sets the output JSON format (compact is much smaller) 123 | [choices: "compact", "verbose"] [default: "verbose"] 124 | 125 | 126 | 127 | 128 | Please support ttag-cli development by sending issues/PRs. 129 | -------------------------------------------------------------------------------- /bin/c-3po: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../dist/src/index.js'); -------------------------------------------------------------------------------- /bin/ttag: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../dist/src/index.js'); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ttag-cli", 3 | "version": "1.10.18", 4 | "main": "index.js", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+ssh://git@github.com/ttag-org/ttag-cli.git" 8 | }, 9 | "author": "", 10 | "license": "MIT", 11 | "scripts": { 12 | "preversion": "npm run build && npm test", 13 | "test": "mkdir -p dist && CI=true jest", 14 | "cli": "ts-node ./src/index.ts", 15 | "build": "tsc", 16 | "pretty": "prettier --write \"./src/**/*.ts\" \"./tests/**/*.ts\"", 17 | "precommit": "lint-staged", 18 | "postinstall": "node ./scripts/install_completion.js", 19 | "readme": "npm run build && node ./scripts/doc.js" 20 | }, 21 | "lint-staged": { 22 | "*.{ts,css,tsx}": [ 23 | "prettier --write", 24 | "git add" 25 | ] 26 | }, 27 | "bin": { 28 | "ttag": "./bin/ttag" 29 | }, 30 | "devDependencies": { 31 | "@types/babel__core": "7.1.2", 32 | "@types/babel__generator": "^7.6.2", 33 | "@types/babel__template": "^7.0.3", 34 | "@types/babel__traverse": "^7.0.15", 35 | "@types/chalk": "^2.2.0", 36 | "@types/estree": "0.0.44", 37 | "@types/jest": "21.1.6", 38 | "@types/koa": "^2.0.49", 39 | "@types/koa-router": "^7.0.42", 40 | "@types/mkdirp": "^0.5.2", 41 | "@types/node": "^12.12.6", 42 | "@types/node-fetch": "^1.6.7", 43 | "@types/ora": "1.3.1", 44 | "@types/readline-sync": "^1.4.2", 45 | "@types/serialize-javascript": "^1.5.0", 46 | "@types/tmp": "^0.0.33", 47 | "@types/walk": "^2.3.0", 48 | "@types/yargs": "8.0.2", 49 | "babel-core": "^7.0.0-bridge.0", 50 | "husky": "^0.14.3", 51 | "jest": "^24.9.0", 52 | "lint-staged": "^5.0.0", 53 | "prettier": "^1.19.1", 54 | "ts-jest": "^26.3.0", 55 | "ts-node": "3.3.0", 56 | "typescript": "^3.9.7" 57 | }, 58 | "dependencies": { 59 | "@babel/core": "^7.12.3", 60 | "@babel/generator": "^7.12.5", 61 | "@babel/plugin-proposal-class-properties": "^7.12.1", 62 | "@babel/plugin-proposal-decorators": "^7.12.1", 63 | "@babel/plugin-proposal-export-default-from": "^7.12.1", 64 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.12.1", 65 | "@babel/plugin-proposal-object-rest-spread": "^7.12.1", 66 | "@babel/plugin-proposal-optional-chaining": "7.6.0", 67 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 68 | "@babel/preset-env": "^7.12.1", 69 | "@babel/preset-flow": "^7.12.1", 70 | "@babel/preset-react": "^7.12.5", 71 | "@babel/preset-typescript": "7.7.0", 72 | "@babel/template": "^7.10.4", 73 | "babel-plugin-ttag": "1.8.16", 74 | "babel-preset-const-enum": "^1.0.0", 75 | "chalk": "^2.4.2", 76 | "cross-spawn": "^5.1.0", 77 | "estree-walker": "^2.0.1", 78 | "gettext-parser": "^6.0.0", 79 | "hunspell-spellchecker": "^1.0.2", 80 | "ignore": "^5.1.8", 81 | "koa": "^2.13.0", 82 | "koa-body": "^4.2.0", 83 | "koa-router": "^9.1.0", 84 | "mkdirp": "^0.5.1", 85 | "node-fetch": "^2.6.1", 86 | "open": "^6.4.0", 87 | "ora": "1.3.0", 88 | "plural-forms": "0.5.3", 89 | "readline-sync": "^1.4.7", 90 | "serialize-javascript": "^4.0.0", 91 | "svelte": "^3.20.1", 92 | "tmp": "0.0.33", 93 | "vue-sfc-parser": "^0.1.2", 94 | "walk": "2.3.9", 95 | "yargs": "^15.4.1" 96 | }, 97 | "jest": { 98 | "transform": { 99 | "^.+\\.tsx?$": "ts-jest" 100 | }, 101 | "testRegex": "(/tests/.*|(\\.|/)(test|spec))\\.(tsx?)$", 102 | "testPathIgnorePatterns": [ 103 | "/tests/fixtures.*" 104 | ], 105 | "moduleFileExtensions": [ 106 | "ts", 107 | "tsx", 108 | "js", 109 | "jsx", 110 | "json" 111 | ] 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /scripts/doc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var fs = require('fs'); 3 | var { execSync } = require('child_process'); 4 | 5 | var readmePath = 'README.md' 6 | var BEGINTAG = '' 7 | var ENDTAG = '' 8 | 9 | var autodoc = execSync('./bin/ttag doc').toString(); 10 | 11 | var readMe = fs.readFileSync(readmePath); 12 | var beginCommandsPos = readMe.indexOf(BEGINTAG) + BEGINTAG.length; 13 | var endCommandsPos = readMe.indexOf(ENDTAG); 14 | fs.writeFileSync( 15 | readmePath, ( 16 | readMe.slice(0, beginCommandsPos) + 17 | '\n\n' + 18 | autodoc + 19 | readMe.slice(endCommandsPos) 20 | ) 21 | ); 22 | -------------------------------------------------------------------------------- /scripts/install_completion.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | var completionDir = '/etc/bash_completion.d/'; 5 | if (fs.existsSync(completionDir)) { 6 | try { 7 | fs.copyFileSync(path.join(__dirname, 'ttag'), path.join(completionDir, 'ttag')); 8 | }catch(e) { 9 | console.warn("Could not install bash completion, run install with --unsafe"); 10 | } 11 | } else { 12 | console.log("Platform does not have completion feature or " + completionDir); 13 | } 14 | -------------------------------------------------------------------------------- /scripts/ttag: -------------------------------------------------------------------------------- 1 | _ttag_completion() { 2 | cur="${COMP_WORDS[COMP_CWORD]}" 3 | COMPREPLY=( $(ttag --get-yargs-completions "${COMP_WORDS[@]:1:$((COMP_CWORD-1))}" -- ${cur} 2>/dev/null) ) 4 | if [[ ${COMPREPLY} == "" ]]; then 5 | COMPREPLY=( $(compgen -f -- ${cur}) ) 6 | fi 7 | return 0 8 | } 9 | 10 | complete -o filenames -F _ttag_completion ttag 11 | -------------------------------------------------------------------------------- /src/commands/check.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Compare designated pofile with pot file extracted from all files in all paths 3 | */ 4 | 5 | import * as ora from "ora"; 6 | import * as fs from "fs"; 7 | import { extractAll } from "../lib/extract"; 8 | import { checkDuplicateKeys } from "../lib/checkDuplicateKeys"; 9 | import * as c3poTypes from "../types"; 10 | import { parse, PoData } from "../lib/parser"; 11 | 12 | /* 13 | Run any string in stream through warning first 14 | */ 15 | function* warningPipe( 16 | pofile: string, 17 | progress: c3poTypes.Progress, 18 | stream: IterableIterator 19 | ): IterableIterator { 20 | for (const str of stream) { 21 | progress.warn(`Translation '${str}' is not found in ${pofile}`); 22 | yield str; 23 | } 24 | } 25 | 26 | /* 27 | Unpack pofile into contextKey/messageId pairs 28 | */ 29 | function* unpackPoData(poData: PoData): IterableIterator<[string, string]> { 30 | for (const contextKey of Object.keys(poData.translations)) { 31 | for (const msgid of Object.keys(poData.translations[contextKey])) { 32 | const keyMsg = poData.translations[contextKey][msgid]; 33 | yield [contextKey, keyMsg.msgid]; 34 | } 35 | } 36 | } 37 | 38 | /* 39 | Find untranslated string by extracting from translations(pofile) 40 | using key/context from keys(pot file). 41 | */ 42 | function* getUntranslated( 43 | translations: PoData, 44 | keysOnly: PoData 45 | ): IterableIterator { 46 | for (const [contextKey, msgid] of unpackPoData(keysOnly)) { 47 | const context = translations.translations[contextKey]; 48 | if (!context || !context[msgid]) { 49 | yield msgid; 50 | continue; 51 | } 52 | const msgstr = context[msgid].msgstr; 53 | if (msgstr.filter(s => !!s).length == 0) { 54 | yield msgid; 55 | } 56 | } 57 | } 58 | 59 | /* 60 | Check all keys from pots(keys only files) are present in pofile(files with translations) 61 | */ 62 | async function check( 63 | pofile: string, 64 | paths: string[], 65 | lang: string, 66 | overrideOpts?: c3poTypes.TtagOpts, 67 | ttagRcOpts?: c3poTypes.TtagRc, 68 | skip?: "translation" 69 | ) { 70 | const progress: c3poTypes.Progress = ora( 71 | `[ttag] checking translations from ${paths} ...` 72 | ); 73 | // progress.start(); 74 | 75 | const translations = parse(fs.readFileSync(pofile).toString()); 76 | const keysOnly = parse( 77 | await extractAll(paths, lang, progress, overrideOpts, ttagRcOpts) 78 | ); 79 | 80 | let untranslatedStream = getUntranslated(translations, keysOnly); 81 | untranslatedStream = warningPipe(pofile, progress, untranslatedStream); 82 | const untranslated = Array.from(untranslatedStream); 83 | 84 | const errMessage = checkDuplicateKeys(translations); 85 | 86 | if (errMessage) { 87 | progress.fail(errMessage); 88 | process.exit(1); 89 | } 90 | 91 | if (untranslated.length && skip !== "translation") { 92 | progress.fail( 93 | `[ttag] has found ${untranslated.length} untranslated string(s)` 94 | ); 95 | process.exit(1); 96 | } else { 97 | // progress.succeed(`[ttag] checked`); 98 | } 99 | } 100 | 101 | export default check; 102 | -------------------------------------------------------------------------------- /src/commands/color.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import * as fs from "fs"; 3 | import { parse } from "../lib/parser"; 4 | import { iterateTranslations } from "../lib/utils"; 5 | import { printHeader, printMsg } from "../lib/print"; 6 | 7 | export default function color(path: string) { 8 | // Force color output even on tty, otherwise this command is useless 9 | chalk.enabled = true; 10 | chalk.level = 1; 11 | 12 | const data = fs.readFileSync(path).toString(); 13 | const poData = parse(data); 14 | printMsg({ msgid: "", msgstr: [""] }); 15 | printHeader(poData.headers); 16 | process.stdout.write("\n"); 17 | const messages = iterateTranslations(poData.translations); 18 | messages.next(); // skip empty translation 19 | for (const msg of messages) { 20 | printMsg(msg); 21 | process.stdout.write("\n"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/commands/extract.ts: -------------------------------------------------------------------------------- 1 | import * as ora from "ora"; 2 | import * as fs from "fs"; 3 | import * as c3poTypes from "../types"; 4 | import { extractAll } from "../lib/extract"; 5 | 6 | async function extract( 7 | output: string, 8 | paths: string[], 9 | lang: string = "en", 10 | ttagOverrideOpts?: c3poTypes.TtagOpts, 11 | ttagRcOpts?: c3poTypes.TtagRc 12 | ) { 13 | const progress: c3poTypes.Progress = ora( 14 | `[ttag] extracting translations to ${output} ...` 15 | ); 16 | progress.start(); 17 | const result = await extractAll( 18 | paths, 19 | lang, 20 | progress, 21 | ttagOverrideOpts, 22 | ttagRcOpts 23 | ); 24 | fs.writeFileSync(output, result); 25 | progress.succeed(`[ttag] translations extracted to ${output}`); 26 | } 27 | 28 | export default extract; 29 | -------------------------------------------------------------------------------- /src/commands/filter.ts: -------------------------------------------------------------------------------- 1 | import { parse, PoData, Message, Messages, Translations } from "../lib/parser"; 2 | import { serialize } from "../lib/serializer"; 3 | import * as fs from "fs"; 4 | 5 | enum RuleType { 6 | Must, 7 | MustNot 8 | } 9 | 10 | interface PoFilterParams { 11 | fuzzy?: boolean; 12 | translated?: boolean; 13 | referenceRe?: RegExp; 14 | } 15 | 16 | type TestFunction = (msg: Message) => boolean; 17 | 18 | function FuzzyTest(msg: Message): boolean { 19 | if (msg.comments == undefined) { 20 | return false; 21 | } 22 | return Boolean(msg.comments.flag && msg.comments.flag.includes("fuzzy")); 23 | } 24 | 25 | function TranslatedTest(msg: Message): boolean { 26 | return msg.msgstr.filter(s => s.length > 0).length == msg.msgstr.length; 27 | } 28 | 29 | function ReferenceReTest(msg: Message, re: RegExp): boolean { 30 | return ( 31 | msg.comments != undefined && 32 | msg.comments.reference != undefined && 33 | re.test(msg.comments.reference) 34 | ); 35 | } 36 | 37 | type FilterRules = Array<[TestFunction, RuleType]>; 38 | 39 | class PoFilter { 40 | fuzzy?: boolean; 41 | translated?: boolean; 42 | referenceRe?: RegExp; 43 | 44 | constructor({ fuzzy, translated, referenceRe }: PoFilterParams) { 45 | this.fuzzy = fuzzy; 46 | this.translated = translated; 47 | this.referenceRe = referenceRe; 48 | } 49 | 50 | /* set fuzzy flag */ 51 | withFuzzy() { 52 | return new PoFilter(Object.assign({}, this, { fuzzy: true })); 53 | } 54 | 55 | /* set no fuzzy flag */ 56 | withoutFuzzy() { 57 | return new PoFilter(Object.assign({}, this, { fuzzy: false })); 58 | } 59 | 60 | /* set translation flag */ 61 | withTranslation() { 62 | return new PoFilter(Object.assign({}, this, { translated: true })); 63 | } 64 | 65 | /* set no translation flag */ 66 | withoutTranslation() { 67 | return new PoFilter(Object.assign({}, this, { translated: false })); 68 | } 69 | 70 | withReferenceRe(referenceRe: RegExp) { 71 | return new PoFilter( 72 | Object.assign({}, this, { referenceRe: referenceRe }) 73 | ); 74 | } 75 | 76 | /* build rule chain according to state and apply to translations */ 77 | apply(translations: Translations): Translations { 78 | const rules = []; 79 | if (this.fuzzy == true) { 80 | rules.push([FuzzyTest, RuleType.Must]); 81 | } 82 | if (this.fuzzy == false) { 83 | rules.push([FuzzyTest, RuleType.MustNot]); 84 | } 85 | if (this.translated == true) { 86 | rules.push([TranslatedTest, RuleType.Must]); 87 | } 88 | if (this.translated == false) { 89 | rules.push([TranslatedTest, RuleType.MustNot]); 90 | } 91 | const reg = this.referenceRe; 92 | if (reg !== undefined) { 93 | rules.push([ 94 | (m: Message) => ReferenceReTest(m, reg), 95 | RuleType.Must 96 | ]); 97 | } 98 | const newTranslations = {}; 99 | for (let [ctxt, messages] of filterTranslationsStream( 100 | translations, 101 | rules 102 | )) { 103 | newTranslations[ctxt] = messages; 104 | } 105 | return newTranslations; 106 | } 107 | } 108 | 109 | /* Test rule according to type */ 110 | function testRule(test: TestFunction, rule: RuleType, msg: Message): boolean { 111 | switch (rule) { 112 | case RuleType.Must: { 113 | return test(msg); 114 | } 115 | case RuleType.MustNot: { 116 | return !test(msg); 117 | } 118 | } 119 | } 120 | 121 | /* Test each message with tester according to rule */ 122 | function* filterMessagesStream( 123 | messages: Messages, 124 | rules: FilterRules 125 | ): IterableIterator { 126 | for (const msgid of Object.keys(messages)) { 127 | const msg = messages[msgid]; 128 | if (msgid == "") { 129 | // skip empty message id 130 | yield msg; 131 | } 132 | 133 | let allPassed = true; 134 | for (let [test, rule] of rules) { 135 | if (!testRule(test, rule, msg)) { 136 | allPassed = false; 137 | break; 138 | } 139 | } 140 | if (allPassed) { 141 | yield msg; 142 | } 143 | } 144 | } 145 | 146 | /* Run all messages by context through filter stream */ 147 | function* filterTranslationsStream( 148 | translations: Translations, 149 | rules: FilterRules 150 | ): IterableIterator<[string, Messages]> { 151 | for (const contextKey of Object.keys(translations)) { 152 | const context = translations[contextKey]; 153 | const newContext = {}; 154 | for (const msg of filterMessagesStream(context, rules)) { 155 | newContext[msg.msgid] = msg; 156 | } 157 | if (Object.keys(newContext).length > 0) { 158 | yield [contextKey, newContext]; 159 | } 160 | } 161 | } 162 | 163 | export default function filter( 164 | path: string, 165 | fuzzy: boolean, 166 | noFuzzy: boolean, 167 | translated: boolean, 168 | notTranslated: boolean, 169 | referenceRe: string 170 | ) { 171 | if (fuzzy && noFuzzy) { 172 | throw "Choose one of fuzzy or no-fuzzy args"; 173 | } 174 | if (translated && notTranslated) { 175 | throw "Choose one of translated or not translated args"; 176 | } 177 | if (referenceRe) { 178 | try { 179 | new RegExp(referenceRe); 180 | } catch { 181 | throw "Invalid regular expression for reference"; 182 | } 183 | } 184 | let filter = new PoFilter({}); 185 | if (fuzzy) { 186 | filter = filter.withFuzzy(); 187 | } 188 | if (noFuzzy) { 189 | filter = filter.withoutFuzzy(); 190 | } 191 | if (translated) { 192 | filter = filter.withTranslation(); 193 | } 194 | if (notTranslated) { 195 | filter = filter.withoutTranslation(); 196 | } 197 | if (referenceRe) { 198 | filter = filter.withReferenceRe(new RegExp(referenceRe)); 199 | } 200 | 201 | const poData = parse(fs.readFileSync(path).toString()); 202 | 203 | const filteredPoData = { 204 | headers: poData.headers, 205 | translations: filter.apply(poData.translations) 206 | }; 207 | process.stdout.write(serialize(filteredPoData)); 208 | } 209 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import * as ora from "ora"; 2 | import * as fs from "fs"; 3 | import * as c3poTypes from "../types"; 4 | import { getPluralFormsHeader, hasLang } from "plural-forms"; 5 | import { langValidationMsg } from "../lib/validation"; 6 | 7 | function generatePoFile(language: string): string { 8 | const pluralFormsHeader = getPluralFormsHeader(language); 9 | return `msgid "" 10 | msgstr "" 11 | "Content-Type: text/plain; charset=UTF-8\\n" 12 | "Plural-Forms: ${pluralFormsHeader};\\n" 13 | "Language: ${language}\\n" 14 | "MIME-Version: 1.0\\n" 15 | "Content-Transfer-Encoding: 8bit\\n" 16 | `; 17 | } 18 | 19 | export default function init(language: string, pofile: string) { 20 | const progress: c3poTypes.Progress = ora(); 21 | if (!hasLang(language)) { 22 | progress.fail(langValidationMsg(language)); 23 | process.exit(1); 24 | return; 25 | } 26 | progress.start(); 27 | const poContent = generatePoFile(language); 28 | fs.writeFileSync(pofile, poContent); 29 | progress.succeed(`[ttag] ${pofile} is created`); 30 | } 31 | -------------------------------------------------------------------------------- /src/commands/merge.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { serialize } from "../lib/serializer"; 3 | import { mergePo } from "../lib/merge"; 4 | import { PoData, parse } from "../lib/parser"; 5 | 6 | /* Read and parse file path into poData */ 7 | function read(path: string): PoData { 8 | return parse(fs.readFileSync(path).toString()); 9 | } 10 | 11 | /* Entry point */ 12 | export default function merge(paths: string[]) { 13 | process.stdout.write(serialize(paths.map(read).reduce(mergePo))); 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/po2json.ts: -------------------------------------------------------------------------------- 1 | import * as ora from "ora"; 2 | import * as ttagTypes from "../types"; 3 | import { parse, PoDataCompact, PoData } from "../lib/parser"; 4 | import { iterateTranslations, convert2Compact } from "../lib/utils"; 5 | import { checkDuplicateKeys } from "../lib/checkDuplicateKeys"; 6 | import * as fs from "fs"; 7 | 8 | export default function po2json( 9 | path: string, 10 | pretty: boolean, 11 | nostrip: boolean, 12 | format: "compact" | "verbose" 13 | ) { 14 | const progress: ttagTypes.Progress = ora( 15 | `[ttag] po2json translation from ${path} ...` 16 | ); 17 | let poData: PoData | PoDataCompact = parse( 18 | fs.readFileSync(path).toString() 19 | ); 20 | const errMessage = checkDuplicateKeys(poData); 21 | 22 | if (errMessage) { 23 | progress.fail(errMessage); 24 | process.exit(1); 25 | } 26 | const messages = iterateTranslations(poData.translations); 27 | if (!nostrip) { 28 | const header = messages.next().value; 29 | delete header.comments; 30 | for (const msg of messages) { 31 | delete msg.comments; 32 | } 33 | } 34 | if (format === "compact") { 35 | poData = convert2Compact(poData); 36 | } 37 | process.stdout.write(JSON.stringify(poData, null, pretty ? 2 : 0)); 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/pseudo.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { parse } from "../lib/parser"; 3 | import { serialize } from "../lib/serializer"; 4 | import { ast2Str } from "../lib/utils"; 5 | import { ExpressionStatement, TemplateLiteral } from "@babel/types"; 6 | import tpl from "@babel/template"; 7 | 8 | const UPPER_A = "A".codePointAt(0); 9 | const UPPER_Z = "Z".codePointAt(0); 10 | const LOWER_A = "a".codePointAt(0); 11 | const LOWER_Z = "z".codePointAt(0); 12 | const ZERO = "0".codePointAt(0); 13 | const NINE = "9".codePointAt(0); 14 | 15 | // Italic Math symbols are shorter than plain text 16 | // const PSEUDO_UPPER_A = 0x1d608; 17 | // const PSEUDO_LOWER_A = 0x1d622; 18 | // const PSEUDO_ZERO = 0x1d7e2; 19 | 20 | // Bold Cursive Math symbols are longer than plain text 21 | const PSEUDO_UPPER_A = 0x1d4d0; 22 | const PSEUDO_LOWER_A = 0x1d4ea; 23 | const PSEUDO_ZERO = 0x1d7ce; 24 | 25 | function pseudoChar(c: string) { 26 | let code = c.codePointAt(0); 27 | if (code >= UPPER_A && code <= UPPER_Z) { 28 | code = code - UPPER_A + PSEUDO_UPPER_A; 29 | } else if (code >= LOWER_A && code <= LOWER_Z) { 30 | code = code - LOWER_A + PSEUDO_LOWER_A; 31 | } else if (code >= ZERO && code <= NINE) { 32 | code = code - ZERO + PSEUDO_ZERO; 33 | } 34 | return String.fromCodePoint(code); 35 | } 36 | 37 | function pseudoString(str: string) { 38 | return Array.from(str) 39 | .map(pseudoChar) 40 | .join(""); 41 | } 42 | 43 | function pseudoExpression(msgid: string) { 44 | const statement = tpl.ast("`" + msgid + "`"); 45 | const expression = statement.expression; 46 | 47 | for (const q of expression.quasis) { 48 | if (q.value.raw) { 49 | q.value.raw = pseudoString(q.value.raw); 50 | } 51 | if (q.value.cooked) { 52 | q.value.cooked = pseudoString(q.value.cooked); 53 | } 54 | } 55 | // FIXME if content has backticks, they are escaped by ast; unescape them here. 56 | return ast2Str(expression).replace(/^`|`$/g, ""); 57 | } 58 | 59 | export default function pseudo(path: string, output: string) { 60 | const poData = parse(fs.readFileSync(path).toString()); 61 | 62 | for (const key of Object.keys(poData.translations)) { 63 | const ctx = poData.translations[key]; 64 | for (const msgid of Object.keys(ctx)) { 65 | const msg = ctx[msgid]; 66 | msg.msgstr = msg.msgstr.map(() => pseudoExpression(msgid)); 67 | } 68 | } 69 | fs.writeFileSync(output, serialize(poData)); 70 | console.log(`Translations written to ${output}`); 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/replace.ts: -------------------------------------------------------------------------------- 1 | import "../declarations"; 2 | import * as ora from "ora"; 3 | import * as c3poTypes from "../types"; 4 | import { makeBabelConf } from "../defaults"; 5 | import * as babel from "@babel/core"; 6 | import * as path from "path"; 7 | import * as fs from "fs"; 8 | import { TransformFn, pathsWalk } from "../lib/pathsWalk"; 9 | import * as mkdirp from "mkdirp"; 10 | 11 | async function replace( 12 | pofile: string, 13 | out: string, 14 | srcPath: string, 15 | overrideOpts?: c3poTypes.TtagOpts 16 | ) { 17 | const progress: c3poTypes.Progress = ora( 18 | `[ttag] replacing source files with translations ...` 19 | ); 20 | progress.start(); 21 | let ttagOpts: c3poTypes.TtagOpts = { 22 | resolve: { translations: pofile } 23 | }; 24 | 25 | if (overrideOpts) { 26 | ttagOpts = Object.assign(ttagOpts, overrideOpts); 27 | } 28 | const babelOptions = makeBabelConf(ttagOpts); 29 | const transformFn: TransformFn = file => { 30 | const relativePath = path.relative(srcPath, file); 31 | const resultPath = path.join(out, relativePath); 32 | const result = babel.transformFileSync(file, babelOptions); 33 | const dir = path.dirname(resultPath); 34 | if (dir !== ".") { 35 | mkdirp.sync(dir); 36 | } 37 | if (!result) { 38 | progress.fail("Failed to replace"); 39 | return; 40 | } 41 | fs.writeFileSync(resultPath, result.code); 42 | }; 43 | 44 | await pathsWalk([srcPath], progress, transformFn); 45 | progress.succeed(`[ttag] replace is done`); 46 | } 47 | 48 | export default replace; 49 | -------------------------------------------------------------------------------- /src/commands/spell.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { parse } from "../lib/parser"; 3 | import * as ora from "ora"; 4 | import * as c3poTypes from "../types"; 5 | import chalk from "chalk"; 6 | import { iterateTranslations } from "../lib/utils"; 7 | import { getChecker, Checker } from "../lib/spell"; 8 | import { printMsg } from "../lib/print"; 9 | 10 | const cleanRe = new RegExp(/[;:,.?"'!\(\)«»]/g); 11 | 12 | export default function spell(path: string, locale?: string) { 13 | // Force color output even on tty, otherwise this command is useless 14 | chalk.enabled = true; 15 | chalk.level = 1; 16 | 17 | const data = fs.readFileSync(path).toString(); 18 | const poData = parse(data); 19 | locale = locale || poData.headers.language || poData.headers.Language; 20 | if (!locale) { 21 | console.log("Cannot detect locale from pofile, please provide it"); 22 | return; 23 | } 24 | const loadProgress: c3poTypes.Progress = ora( 25 | `Loading dict for ${locale}...` 26 | ); 27 | loadProgress.start(); 28 | getChecker(locale).then(function(checker: Checker) { 29 | loadProgress.succeed(`${locale} dict loaded`); 30 | const checkProgress: c3poTypes.Progress = ora( 31 | `Checking pofile ${path}...\n\n` 32 | ); 33 | checkProgress.start(); 34 | const messages = iterateTranslations(poData.translations); 35 | messages.next(); // skip headers 36 | for (const msg of messages) { 37 | let hasErrors = false; 38 | for (let i = 0; i < msg.msgstr.length; i++) { 39 | for (const word of msg.msgstr[i].split(" ")) { 40 | const cleanWord = word.replace(cleanRe, ""); 41 | if (cleanWord && !checker.check(cleanWord)) { 42 | msg.msgstr[i] = msg.msgstr[i].replace( 43 | cleanWord, 44 | chalk.underline.bgRed(cleanWord) 45 | ); 46 | hasErrors = true; 47 | } 48 | } 49 | } 50 | if (hasErrors) { 51 | printMsg(msg); 52 | console.log("\n"); 53 | } 54 | } 55 | checkProgress.succeed(`${path} checked`); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /src/commands/stats.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import chalk from "chalk"; 3 | import { parse, Translations, Message } from "../lib/parser"; 4 | 5 | type PoStats = { 6 | total: number; 7 | translated: number; 8 | fuzzy: number; 9 | contexts: number; 10 | }; 11 | 12 | function nonEmpty(el: string) { 13 | return !!el; 14 | } 15 | 16 | function isTranslated(msg: Message): boolean { 17 | return msg.msgstr.filter(nonEmpty).length == msg.msgstr.length; 18 | } 19 | 20 | function isFuzzy(msg: Message): boolean { 21 | return msg.comments != undefined && msg.comments.flag == "fuzzy"; 22 | } 23 | 24 | function statsCalculator(translations: Translations): PoStats { 25 | let [total, translated, fuzzy, contexts] = [0, 0, 0, 0]; 26 | for (const contextKey of Object.keys(translations)) { 27 | contexts += 1; 28 | const context = translations[contextKey]; 29 | for (const msgid of Object.keys(context)) { 30 | const msg = context[msgid]; 31 | total += 1; 32 | translated += isTranslated(msg) ? 1 : 0; 33 | fuzzy += isFuzzy(msg) ? 1 : 0; 34 | } 35 | } 36 | return { 37 | total, 38 | translated, 39 | fuzzy, 40 | contexts 41 | }; 42 | } 43 | 44 | export default function stats(path: string) { 45 | const poData = parse(fs.readFileSync(path).toString()); 46 | const poStats = statsCalculator(poData.translations); 47 | console.log(`${chalk.green("TOTAL:")} ${poStats.total}`); 48 | console.log(`${chalk.green("CONTEXTS:")} ${poStats.contexts}`); 49 | console.log(`${chalk.green("TRANSLATED:")} ${poStats.translated}`); 50 | console.log(`${chalk.green("FUZZY:")} ${poStats.fuzzy}`); 51 | 52 | let indicators = []; 53 | const maxLength = 50; 54 | const filledGreen = Math.round( 55 | (poStats.translated - poStats.fuzzy) / poStats.total * maxLength 56 | ); 57 | const filledYellow = Math.round(poStats.fuzzy / poStats.total * maxLength); 58 | const fillledRed = maxLength - filledGreen - filledYellow; 59 | 60 | for (let i = 0; i < filledGreen; i++) { 61 | indicators.push(chalk.green("#")); 62 | } 63 | for (let i = 0; i < filledYellow; i++) { 64 | indicators.push(chalk.yellow("#")); 65 | } 66 | for (let i = 0; i < fillledRed; i++) { 67 | indicators.push(chalk.gray("·")); 68 | } 69 | const translatedPercent = poStats.translated / poStats.total; 70 | console.log( 71 | `[${indicators.join("")}] ${Math.round(translatedPercent * 100)}% ` + 72 | `${poStats.translated - poStats.fuzzy}/${poStats.total - 73 | poStats.translated}/${poStats.total}` 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/commands/translate.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import * as readlineSync from "readline-sync"; 3 | import chalk from "chalk"; 4 | import { parse, Translations, PoData } from "../lib/parser"; 5 | import { serialize } from "../lib/serializer"; 6 | import { 7 | printComments, 8 | printContext, 9 | printMsgid, 10 | printMsgidPlural 11 | } from "../lib/print"; 12 | 13 | /* Generate untranslated messages along with context */ 14 | export function* untranslatedStream(translations: Translations): any { 15 | for (const contextKey of Object.keys(translations)) { 16 | const context = translations[contextKey]; 17 | for (const msgid of Object.keys(context)) { 18 | const msg = context[msgid]; 19 | msg.msgstr = yield [contextKey, msg]; 20 | } 21 | } 22 | } 23 | 24 | /* Read file and parse it shorthand */ 25 | export function read(path: string): PoData { 26 | return parse(fs.readFileSync(path).toString()); 27 | } 28 | 29 | /* Stream translations for each form if many(plural) */ 30 | function* translationStream(msgstr: string[]): IterableIterator { 31 | if (msgstr.length > 1) { 32 | for (let i = 0; i < msgstr.length; i++) { 33 | yield readlineSync.question(chalk.yellow(`msgstr[${i}]: `)); 34 | } 35 | } else { 36 | yield readlineSync.question(chalk.yellow(`msgstr: `)); 37 | } 38 | } 39 | 40 | export default function translate(path: string, output: string) { 41 | const poData = read(path); 42 | const stream = untranslatedStream(poData.translations); 43 | // skip first message(empty msgid in header) 44 | stream.next(); 45 | var { value, done } = stream.next(""); 46 | while (!done) { 47 | let [ctxt, msg] = value; 48 | printComments(msg.comments); 49 | printContext(ctxt); 50 | printMsgid(msg.msgid); 51 | printMsgidPlural(msg.msgid_plural); 52 | const translation = Array.from(translationStream(msg.msgstr)); 53 | const data = stream.next(translation); 54 | [value, done] = [data.value, data.done]; 55 | console.log(); 56 | } 57 | fs.writeFileSync(output, serialize(poData)); 58 | console.log(`Translations written to ${output}`); 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/update.ts: -------------------------------------------------------------------------------- 1 | import * as ora from "ora"; 2 | import * as ttagTypes from "../types"; 3 | import * as fs from "fs"; 4 | import { extractAll } from "../lib/extract"; 5 | import { updatePo } from "../lib/update"; 6 | import { parse } from "../lib/parser"; 7 | import { serialize, SerializeOptions } from "../lib/serializer"; 8 | import { checkDuplicateKeys } from "../lib/checkDuplicateKeys"; 9 | 10 | async function update( 11 | pofile: string, 12 | src: string[], 13 | lang: string, 14 | ttagOverrideOpts?: ttagTypes.TtagOpts, 15 | ttagRcOpts?: ttagTypes.TtagRc, 16 | serializeOpts?: SerializeOptions 17 | ) { 18 | const progress: ttagTypes.Progress = ora(`[ttag] updating ${pofile} ...`); 19 | progress.start(); 20 | try { 21 | const pot = parse( 22 | await extractAll(src, lang, progress, ttagOverrideOpts, ttagRcOpts) 23 | ); 24 | const errMessage = checkDuplicateKeys(pot); 25 | 26 | if (errMessage) { 27 | progress.fail(errMessage); 28 | process.exit(1); 29 | } 30 | const po = parse(fs.readFileSync(pofile).toString()); 31 | const resultPo = updatePo(pot, po); 32 | fs.writeFileSync(pofile, serialize(resultPo, serializeOpts)); 33 | progress.succeed(`${pofile} updated`); 34 | } catch (err) { 35 | progress.fail(`Failed to update. ${err.message}. ${err.stack}`); 36 | process.exit(1); 37 | } 38 | } 39 | 40 | export default update; 41 | -------------------------------------------------------------------------------- /src/commands/validate.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { parse } from "../lib/parser"; 3 | import chalk from "chalk"; 4 | import { printMsg } from "../lib/print"; 5 | import { iterateTranslations } from "../lib/utils"; 6 | import { checkFormat } from "../lib/validation"; 7 | 8 | export default function validate(path: string) { 9 | // Force color output even on tty, otherwise this command is useless 10 | chalk.enabled = true; 11 | chalk.level = 1; 12 | 13 | let hasErrors = false; 14 | const data = fs.readFileSync(path).toString(); 15 | const poData = parse(data); 16 | const messages = iterateTranslations(poData.translations); 17 | messages.next(); // skip headers 18 | for (const msg of messages) { 19 | let invalid = false; 20 | for (let i = 0; i < msg.msgstr.length; i++) { 21 | if (!msg.msgstr[i]) { 22 | continue; 23 | } 24 | const result = checkFormat(msg.msgid, msg.msgstr[0]); 25 | if (!result.valid) { 26 | invalid = true; 27 | msg.msgstr[i] = chalk.underline.bgRed(msg.msgstr[i]); 28 | const missing = result.missing.length 29 | ? `missing ${result.missing.join(" and ")}` 30 | : ""; 31 | const redundant = result.redundant.length 32 | ? `redundant ${result.redundant.join(" and ")}` 33 | : ""; 34 | const explanation = chalk.green( 35 | [missing, redundant].filter(s => !!s).join(" but ") 36 | ); 37 | msg.msgstr[i] += ` <--- ${explanation};`; 38 | } 39 | if (invalid) { 40 | hasErrors = true; 41 | printMsg(msg); 42 | console.log("\n"); 43 | } 44 | } 45 | } 46 | if (hasErrors) { 47 | throw new Error("Errors during validation"); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/commands/web.ts: -------------------------------------------------------------------------------- 1 | import openBrowser from "../lib/browser"; 2 | import * as Application from "koa"; 3 | import * as Router from "koa-router"; 4 | import * as koaBody from "koa-body"; 5 | import * as fs from "fs"; 6 | 7 | async function editor(ctx: Application.Context) { 8 | ctx.body = ` 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | C-3po editor 18 | 19 | 20 | 23 |
24 | 40 | 41 | `; 42 | } 43 | 44 | export default function translate(path: string) { 45 | /* define open/save handlers here to capture that path */ 46 | async function open(ctx: Application.Context) { 47 | ctx.set("Content-Type", "text/plain"); 48 | ctx.body = fs.readFileSync(path).toString(); 49 | } 50 | 51 | async function save(ctx: Application.Context) { 52 | const translatedData = ctx.request.body; 53 | fs.writeFileSync(path, translatedData); 54 | ctx.body = "ok"; 55 | ctx.status = 200; 56 | } 57 | 58 | const app = new Application(); 59 | const router = new Router(); 60 | router 61 | .get("/", editor) 62 | .get("/open", open) 63 | //debug static serve 64 | .get("/bundle.js", ctx => (ctx.body = fs.readFileSync("bundle.js"))) 65 | .post("/save", save); 66 | 67 | app.use(koaBody()); 68 | app.use(router.routes()); 69 | app.listen(3000); 70 | openBrowser(`http://127.0.0.1:3000/`); 71 | } 72 | -------------------------------------------------------------------------------- /src/declarations.ts: -------------------------------------------------------------------------------- 1 | declare module "walk"; 2 | declare module "cross-spawn"; 3 | declare module "hunspell-spellchecker"; 4 | declare module "gettext-parser"; 5 | declare module "gettext-parser/lib/shared"; 6 | declare module "babel-plugin-ttag"; 7 | declare module "@babel/preset-react"; 8 | declare module "@babel/preset-env"; 9 | declare module "@babel/plugin-proposal-class-properties"; 10 | declare module "@babel/plugin-proposal-object-rest-spread"; 11 | declare module "@babel/plugin-proposal-export-default-from"; 12 | declare module "@babel/plugin-syntax-dynamic-import"; 13 | declare module "@babel/plugin-proposal-decorators"; 14 | declare module "@babel/preset-typescript"; 15 | declare module "@babel/preset-flow"; 16 | declare module "@babel/plugin-proposal-optional-chaining"; 17 | declare module "@babel/plugin-proposal-nullish-coalescing-operator"; 18 | declare module "babel-preset-const-enum"; 19 | declare module "magic-string" { 20 | export type SourceMap = any; 21 | } 22 | 23 | declare module "vue-sfc-parser"; 24 | -------------------------------------------------------------------------------- /src/defaults.ts: -------------------------------------------------------------------------------- 1 | import "./declarations"; 2 | // presets 3 | import * as presetEnv from "@babel/preset-env"; 4 | import * as presetReact from "@babel/preset-react"; 5 | import * as presetTS from "@babel/preset-typescript"; 6 | import * as presetFlow from "@babel/preset-flow"; 7 | import { TransformOptions, ConfigItem } from "@babel/core"; 8 | import * as ttagTypes from "./types"; 9 | 10 | // plugins 11 | import * as classPropPlugin from "@babel/plugin-proposal-class-properties"; 12 | import * as restSpreadPlugin from "@babel/plugin-proposal-object-rest-spread"; 13 | import * as exportDefaultFromPlugin from "@babel/plugin-proposal-export-default-from"; 14 | import * as babelTtagPlugin from "babel-plugin-ttag"; 15 | import * as babelDynamicImportPlugin from "@babel/plugin-syntax-dynamic-import"; 16 | import * as babelPluginDecorators from "@babel/plugin-proposal-decorators"; 17 | import * as optionalChaningPlugin from "@babel/plugin-proposal-optional-chaining"; 18 | import * as nullishCoalescingOperatorPlugin from "@babel/plugin-proposal-nullish-coalescing-operator"; 19 | import * as presetConstEnumTS from "babel-preset-const-enum"; 20 | 21 | export const defaultPlugins: ConfigItem[] = [ 22 | [babelPluginDecorators, { legacy: true }], 23 | [classPropPlugin, { loose: true }], 24 | restSpreadPlugin, 25 | exportDefaultFromPlugin, 26 | babelDynamicImportPlugin, 27 | optionalChaningPlugin, 28 | nullishCoalescingOperatorPlugin 29 | ]; 30 | 31 | export const defaultPresets: ConfigItem[] = [ 32 | presetTS, 33 | presetConstEnumTS, 34 | presetFlow, 35 | [presetEnv, { loose: true, targets: "node 6.5" }], 36 | presetReact 37 | ]; 38 | 39 | export function makeBabelConf(ttagOpts: ttagTypes.TtagOpts): TransformOptions { 40 | return { 41 | presets: [...defaultPresets], 42 | plugins: [...defaultPlugins, [babelTtagPlugin, ttagOpts]] 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as yargs from "yargs"; 2 | import { Options } from "yargs"; 3 | import extract from "./commands/extract"; 4 | import check from "./commands/check"; 5 | import merge from "./commands/merge"; 6 | import init from "./commands/init"; 7 | import update from "./commands/update"; 8 | import translate from "./commands/translate"; 9 | import filter from "./commands/filter"; 10 | import stats from "./commands/stats"; 11 | import replace from "./commands/replace"; 12 | import color from "./commands/color"; 13 | import pseudo from "./commands/pseudo"; 14 | import spell from "./commands/spell"; 15 | import validate from "./commands/validate"; 16 | import web from "./commands/web"; 17 | import po2js from "./commands/po2json"; 18 | import { 19 | getTtagOptsForYargs, 20 | parseTtagPluginOpts 21 | } from "./lib/ttagPluginOverride"; 22 | import { parseTtagRcOpts } from "./lib/ttagRcOverride"; 23 | 24 | import "./declarations"; 25 | 26 | declare module "yargs" { 27 | interface Argv { 28 | /* we need this to be able to make "hidden" commands: https://github.com/yargs/yargs/pull/190 */ 29 | command( 30 | command: string | string[], 31 | description: string | boolean, 32 | builder: { [optionName: string]: Options }, 33 | handler: (args: Arguments) => void 34 | ): Argv; 35 | } 36 | 37 | interface Command { 38 | original: string; 39 | description: string; 40 | handler: Function; 41 | builder: { [key: string]: Options }; 42 | } 43 | 44 | interface CommandInstance { 45 | getCommands: () => string[]; 46 | getCommandHandlers: () => { [key: string]: Command }; 47 | } 48 | 49 | interface UsageInstance { 50 | example: (cmd: string, desc: string) => void; 51 | } 52 | 53 | interface Argv { 54 | getCommandInstance: () => CommandInstance; 55 | getUsageInstance: () => UsageInstance; 56 | } 57 | } 58 | 59 | yargs.usage("$0 [args]"); 60 | /* Monkey patch example func of usage to store examples locally */ 61 | /* TODO: contribute a patch to make examples available through usage instance */ 62 | const usage = yargs.getUsageInstance(); 63 | const exampleMap: Map = new Map(); 64 | 65 | const originalExampleFunc = usage.example; 66 | 67 | usage.example = (cmd: string, desc: string) => { 68 | originalExampleFunc(cmd, desc); 69 | exampleMap.set(cmd, desc); 70 | }; 71 | 72 | yargs 73 | .command( 74 | "extract [output|lang] ", 75 | "will extract translations to .pot file", 76 | { 77 | output: { 78 | alias: "o", 79 | default: "translations.pot", 80 | description: "result file with translations (.pot)" 81 | }, 82 | lang: { 83 | alias: "l", 84 | default: "en", 85 | description: "sets default lang (ISO format)" 86 | }, 87 | ...getTtagOptsForYargs() 88 | }, 89 | argv => { 90 | extract( 91 | argv.output, 92 | argv.src, 93 | argv.lang, 94 | parseTtagPluginOpts(argv), 95 | parseTtagRcOpts() 96 | ); 97 | } 98 | ) 99 | .command( 100 | "check [lang] ", 101 | "will check if all translations are present in .po file", 102 | { 103 | lang: { 104 | alias: "l", 105 | default: "en", 106 | description: "sets default lang (ISO format)" 107 | }, 108 | skip: { 109 | description: "let skip translation check", 110 | choices: ["translation"], 111 | default: undefined 112 | }, 113 | ...getTtagOptsForYargs() 114 | }, 115 | argv => { 116 | check( 117 | argv.pofile, 118 | argv.src, 119 | argv.lang, 120 | parseTtagPluginOpts(argv), 121 | parseTtagRcOpts(), 122 | argv.skip 123 | ); 124 | } 125 | ) 126 | .command( 127 | "merge ", 128 | "will merge two or more po(t) files together using first non-empty msgstr and header from left-most file", 129 | {}, 130 | argv => { 131 | merge(argv.path); 132 | } 133 | ) 134 | .command( 135 | "translate [args]", 136 | "will open interactive prompt to translate all msgids with empty msgstr in cli", 137 | { 138 | output: { 139 | alias: "o", 140 | default: "translated.po", 141 | description: "result file with translations (.po)" 142 | } 143 | }, 144 | argv => { 145 | translate(argv.path, argv.output); 146 | } 147 | ) 148 | .command( 149 | "stats ", 150 | "will display various pofile statistics(encoding, plurals, translated, fuzzyness)", 151 | {}, 152 | argv => { 153 | stats(argv.path); 154 | } 155 | ) 156 | .command( 157 | "filter [args]", 158 | "will filter pofile by entry attributes(fuzzy, obsolete, (un)translated)", 159 | { 160 | fuzzy: { 161 | alias: "f", 162 | description: "result file with fuzzy messages (.po)", 163 | boolean: true, 164 | default: false 165 | }, 166 | "no-fuzzy": { 167 | alias: "nf", 168 | description: "result file without fuzzy messages (.po)", 169 | boolean: true, 170 | default: false 171 | }, 172 | translated: { 173 | alias: "t", 174 | description: "result file with translations (.po)", 175 | boolean: true, 176 | default: false 177 | }, 178 | "not-translated": { 179 | alias: "nt", 180 | description: "result file without translations (.po)", 181 | boolean: true, 182 | default: false 183 | }, 184 | reference: { 185 | alias: "r", 186 | description: "a regexp to match references against", 187 | default: "" 188 | } 189 | }, 190 | argv => { 191 | filter( 192 | argv.path, 193 | argv.fuzzy, 194 | argv["no-fuzzy"], 195 | argv.translated, 196 | argv["not-translated"], 197 | argv.reference 198 | ); 199 | } 200 | ) 201 | .command( 202 | "init ", 203 | "will create an empty .po file with all necessary headers for the locale", 204 | { 205 | lang: { 206 | description: "sets default locale (ISO format)", 207 | default: "en" 208 | }, 209 | filename: { 210 | description: "path to the .po file" 211 | } 212 | }, 213 | argv => { 214 | init(argv.lang, argv.filename); 215 | } 216 | ) 217 | .command( 218 | "update [opts] ", 219 | "will update existing po file. Add/remove new translations", 220 | { 221 | lang: { 222 | description: "sets default locale (ISO format)", 223 | default: "en" 224 | }, 225 | pofile: { 226 | description: "path to .po file with translations" 227 | }, 228 | src: { 229 | description: "path to source files/directories" 230 | }, 231 | ...getTtagOptsForYargs(), 232 | foldLength: { 233 | description: "line width in .po file", 234 | number: true 235 | } 236 | }, 237 | argv => { 238 | update( 239 | argv.pofile, 240 | argv.src, 241 | argv.lang, 242 | parseTtagPluginOpts(argv), 243 | parseTtagRcOpts(), 244 | { 245 | foldLength: argv.foldLength 246 | } 247 | ); 248 | } 249 | ) 250 | .command( 251 | "replace [options] ", 252 | "will replace all strings with translations from the .po file", 253 | { ...getTtagOptsForYargs() }, 254 | argv => { 255 | replace( 256 | argv.pofile, 257 | argv.out, 258 | argv.path, 259 | parseTtagPluginOpts(argv) 260 | ); 261 | } 262 | ) 263 | .command( 264 | "color ", 265 | "will output po(t)file with pretty colors on, combine with | less -r", 266 | {}, 267 | argv => { 268 | color(argv.pofile); 269 | } 270 | ) 271 | .command( 272 | "pseudo [args]", 273 | "will output a pseudo-localised translation", 274 | { 275 | output: { 276 | alias: "o", 277 | default: "pseudo.po", 278 | description: "result file with pseudo translations (.po)" 279 | } 280 | }, 281 | argv => { 282 | pseudo(argv.path, argv.output); 283 | } 284 | ) 285 | .command( 286 | "spell [locale]", 287 | "will spellcheck po file messages with given locale, locale can be autodetected from pofile", 288 | {}, 289 | argv => { 290 | spell(argv.pofile, argv.locale); 291 | } 292 | ) 293 | .command( 294 | "validate ", 295 | "will validate js template strings (`${x}`) in messages and translations and against each other", 296 | {}, 297 | argv => { 298 | validate(argv.pofile); 299 | } 300 | ) 301 | .command("web ", "will open pofile in web editor", {}, argv => { 302 | web(argv.pofile); 303 | }) 304 | .command( 305 | "po2json [args]", 306 | "will parse and output po file as loadable JSON", 307 | { 308 | pretty: { 309 | alias: "p", 310 | description: "pretty print js", 311 | boolean: true, 312 | default: false 313 | }, 314 | nostrip: { 315 | alias: "n", 316 | description: "do not strip comments/headers", 317 | boolean: true, 318 | default: false 319 | }, 320 | format: { 321 | description: 322 | "sets the output JSON format (compact is much smaller)", 323 | choices: ["compact", "verbose"], 324 | default: "verbose" 325 | } 326 | }, 327 | argv => { 328 | po2js(argv.pofile, argv.pretty, argv.nostrip, argv.format); 329 | } 330 | ) 331 | .command("doc", false, {}, _ => { 332 | const isIgnored = (c: string) => 333 | c == "doc" || c == "completion" || c == "$0"; 334 | const printOption = (name: string, option: Options) => { 335 | return ( 336 | `\t-${name}` + 337 | (option.alias ? ` --${option.alias}` : "") + 338 | ` ${option.description} ` + 339 | (option.default !== undefined 340 | ? `(default: ${option.default})` 341 | : "") + 342 | `\n` 343 | ); 344 | }; 345 | 346 | for (const commandName of Object.keys(handlers)) { 347 | if (isIgnored(commandName)) { 348 | continue; 349 | } 350 | const command = handlers[commandName]; 351 | const options = handlers[commandName].builder; 352 | const optionNames = Object.keys(options); 353 | process.stdout.write( 354 | `### \`${command.original}\`` + 355 | `\n` + 356 | `${command.description}` + 357 | `\n` + 358 | (optionNames.length > 0 359 | ? `#### Arguments:\n` + 360 | optionNames.reduce( 361 | (body: string, optname: string) => 362 | body + printOption(optname, options[optname]), 363 | "" 364 | ) 365 | : "") + 366 | (exampleMap.has(commandName) 367 | ? `#### Example:\n` + exampleMap.get(commandName) 368 | : "") + 369 | `\n\n` 370 | ); 371 | } 372 | }) 373 | .command("*", "", {}, argv => { 374 | const possibleCommand = commands.find(s => s.startsWith(argv._[0])); 375 | if (possibleCommand) { 376 | process.stdout.write(`Did you mean ${possibleCommand}? \n`); 377 | } else { 378 | process.stdout.write(`command "${argv._[0]}" is not found.\n`); 379 | } 380 | process.stdout.write("Use 'ttag --help' to see available commands? \n"); 381 | }) 382 | .completion("completion", (current: string, argv: any, done) => { 383 | if (commands.indexOf(argv._[0]) != -1) { 384 | // argv._[0] is a current first argument, if it is a command 385 | // we should return empty to allow filesystem autocompletion 386 | done([]); 387 | } else if (argv._.length == 0) { 388 | // Return full commands list when user did not input anything 389 | done(commands); 390 | } else { 391 | // Suggest command which starts with user input 392 | done(commands.filter(c => c.indexOf(current) == 0)); 393 | } 394 | }) 395 | .example( 396 | "filter", 397 | "\t ttag filter -nt small.po\n\n" + '\t msgid "test"\n' + '\t msgstr ""' 398 | ); 399 | 400 | const commandInstance = yargs.getCommandInstance(); 401 | export const handlers = commandInstance.getCommandHandlers(); 402 | const commands = commandInstance 403 | .getCommands() 404 | .filter(c => c != "$0" && c != "completion"); 405 | 406 | yargs.help().argv; 407 | -------------------------------------------------------------------------------- /src/lib/browser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2015-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | "use strict"; 9 | 10 | import chalk from "chalk"; 11 | import { execSync } from "child_process"; 12 | import * as spawn from "cross-spawn"; 13 | import opn = require("open"); 14 | 15 | // https://github.com/sindresorhus/opn#app 16 | const OSX_CHROME = "google chrome"; 17 | 18 | enum Actions { 19 | NONE = 0, 20 | BROWSER = 1, 21 | SCRIPT = 2 22 | } 23 | 24 | interface BrowserEnv { 25 | value: string; 26 | action: Actions; 27 | } 28 | 29 | function getBrowserEnv(): BrowserEnv { 30 | // Attempt to honor this environment variable. 31 | // It is specific to the operating system. 32 | // See https://github.com/sindresorhus/opn#app for documentation. 33 | const value = process.env.BROWSER || ""; 34 | let action; 35 | if (!value) { 36 | // Default. 37 | action = Actions.BROWSER; 38 | } else if (value.toLowerCase().endsWith(".js")) { 39 | action = Actions.SCRIPT; 40 | } else if (value.toLowerCase() === "none") { 41 | action = Actions.NONE; 42 | } else { 43 | action = Actions.BROWSER; 44 | } 45 | return { action, value }; 46 | } 47 | 48 | function executeNodeScript(scriptPath: string, url: string) { 49 | const extraArgs = process.argv.slice(2); 50 | const child = spawn("node", [scriptPath, ...extraArgs, url], { 51 | stdio: "inherit" 52 | }); 53 | child.on("close", (code: number) => { 54 | if (code !== 0) { 55 | console.log(); 56 | console.log( 57 | chalk.red( 58 | "The script specified as BROWSER environment variable failed." 59 | ) 60 | ); 61 | console.log( 62 | chalk.cyan(scriptPath) + " exited with code " + code + "." 63 | ); 64 | console.log(); 65 | return; 66 | } 67 | }); 68 | return true; 69 | } 70 | 71 | function startBrowserProcess(browser: string, url: string) { 72 | // If we're on OS X, the user hasn't specifically 73 | // requested a different browser, we can try opening 74 | // Chrome with AppleScript. This lets us reuse an 75 | // existing tab when possible instead of creating a new one. 76 | const shouldTryOpenChromeWithAppleScript = 77 | process.platform === "darwin" && 78 | (browser === "" || browser === OSX_CHROME); 79 | 80 | if (shouldTryOpenChromeWithAppleScript) { 81 | try { 82 | // Try our best to reuse existing tab 83 | // on OS X Google Chrome with AppleScript 84 | execSync('ps cax | grep "Google Chrome"'); 85 | execSync( 86 | 'osascript openChrome.applescript "' + encodeURI(url) + '"', 87 | { 88 | cwd: __dirname, 89 | stdio: "ignore" 90 | } 91 | ); 92 | return true; 93 | } catch (err) { 94 | // Ignore errors. 95 | } 96 | } 97 | 98 | // Another special case: on OS X, check if BROWSER has been set to "open". 99 | // In this case, instead of passing `open` to `opn` (which won't work), 100 | // just ignore it (thus ensuring the intended behavior, i.e. opening the system browser): 101 | // https://github.com/facebookincubator/create-react-app/pull/1690#issuecomment-283518768 102 | if (process.platform === "darwin" && browser === "open") { 103 | browser = ""; 104 | } 105 | 106 | // Fallback to opn 107 | // (It will always open new tab) 108 | try { 109 | var options = { app: browser }; 110 | opn(url, options).catch(() => {}); // Prevent `unhandledRejection` error. 111 | return true; 112 | } catch (err) { 113 | return false; 114 | } 115 | } 116 | 117 | /** 118 | * Reads the BROWSER evironment variable and decides what to do with it. Returns 119 | * true if it opened a browser or ran a node.js script, otherwise false. 120 | */ 121 | export default function openBrowser(url: string) { 122 | const { action, value } = getBrowserEnv(); 123 | switch (action) { 124 | case Actions.NONE: 125 | // Special case: BROWSER="none" will prevent opening completely. 126 | return false; 127 | case Actions.SCRIPT: 128 | return executeNodeScript(value, url); 129 | case Actions.BROWSER: 130 | return startBrowserProcess(value, url); 131 | default: 132 | throw new Error("Not implemented."); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/lib/checkDuplicateKeys.ts: -------------------------------------------------------------------------------- 1 | import { PoData } from "./parser"; 2 | 3 | export const getMsgid = (str: TemplateStringsArray, exprs: Array) => { 4 | const result = []; 5 | const exprsLenght = exprs.length; 6 | const strLength = str.length; 7 | for (let i = 0; i < strLength; i++) { 8 | const expr = i < exprsLenght ? `\${${i}}` : ""; 9 | result.push(str[i] + expr); 10 | } 11 | return result.join(""); 12 | }; 13 | 14 | const stringableRewindingIterator = () => ({ 15 | values: [] as number[], 16 | index: -1, 17 | toString() { 18 | this.index = (this.index + 1) % this.values.length; 19 | return this.values[this.index].toString(); 20 | } 21 | }); 22 | 23 | const removeSpaces = (str: string) => str.replace(/\s/g, ""); 24 | 25 | const mem: { [key: string]: ReturnType } = {}; 26 | // eslint-disable-next-line no-unused-vars 27 | const memoize1 = (f: (i: string) => ReturnType) => ( 28 | arg: string 29 | ) => { 30 | if (mem[arg]) { 31 | return mem[arg]; 32 | } 33 | mem[arg] = f(arg); 34 | return mem[arg]; 35 | }; 36 | 37 | const reg = (i: string) => 38 | new RegExp(`\\$\\{(?:[\\s]+?|\\s?)${i}(?:[\\s]+?|\\s?)}`); 39 | const memReg = memoize1(reg); 40 | 41 | export const msgid2Orig = (id: string, exprs: Array): string => { 42 | return exprs.reduce( 43 | (r, expr, i) => r.replace(memReg(String(i)), String(expr)), 44 | id 45 | ); 46 | }; 47 | 48 | export const buildStr = (strs: TemplateStringsArray, exprs: Array) => { 49 | const exprsLength = exprs.length - 1; 50 | return strs.reduce( 51 | (r, s, i) => r + s + (i <= exprsLength ? exprs[i] : ""), 52 | "" 53 | ); 54 | }; 55 | 56 | export const buildArr = (strs: TemplateStringsArray, exprs: Array) => { 57 | return strs.reduce((r, s, i) => { 58 | return exprs[i] !== undefined ? r.concat(s, exprs[i]) : r.concat(s); 59 | }, [] as Array); 60 | }; 61 | 62 | function pluralFnBody(pluralStr: string) { 63 | return `return args[+ (${pluralStr})];`; 64 | } 65 | 66 | const fnCache: { [key: string]: ReturnType } = {}; 67 | export function makePluralFunc(pluralStr: string) { 68 | let fn = fnCache[pluralStr]; 69 | if (!fn) { 70 | fn = new Function("n", "args", pluralFnBody(pluralStr)); 71 | fnCache[pluralStr] = fn; 72 | } 73 | return fn; 74 | } 75 | 76 | const pluralRegex = /\splural ?=?([\s\S]*);?/; 77 | export function getPluralFunc(headers: { [headerName: string]: string }) { 78 | const pluralFormsHeader = 79 | headers["plural-forms"] || headers["Plural-Forms"]; 80 | if (!pluralFormsHeader) { 81 | throw new Error( 82 | 'po. data should include "language" or "plural-form" header for ngettext' 83 | ); 84 | } 85 | let pluralFn = pluralRegex.exec(pluralFormsHeader)?.[1] || []; 86 | if (pluralFn[pluralFn.length - 1] === ";") { 87 | pluralFn = pluralFn.slice(0, -1); 88 | } 89 | return pluralFn; 90 | } 91 | 92 | const variableREG = /\$\{\s*([.\w+\[\]])*\s*\}/g; 93 | 94 | function getObjectKeys(obj: { [key: string]: unknown }) { 95 | const keys = []; 96 | for (const [key] of Object.entries(obj)) { 97 | if (obj.hasOwnProperty(key)) { 98 | keys.push(key); 99 | } 100 | } 101 | return keys; 102 | } 103 | 104 | function replaceVariables(str: string, obj: { [key: string]: unknown }) { 105 | return str.replace(variableREG, variable => { 106 | return `\$\{${obj[removeSpaces(variable)]}\}`; 107 | }); 108 | } 109 | 110 | function getVariablesMap(msgid: string) { 111 | const variableNumberMap: { 112 | [key: string]: ReturnType; 113 | } = {}; 114 | const variables: string[] | null = msgid.match(variableREG); 115 | if (!variables) return null; 116 | for (let i = 0; i < variables.length; i++) { 117 | const k = removeSpaces(variables[i]); 118 | variableNumberMap[k] = 119 | variableNumberMap[k] || stringableRewindingIterator(); 120 | variableNumberMap[k].values.push(i); 121 | } 122 | return variableNumberMap; 123 | } 124 | 125 | function transformCompactTranslate(msgid: string): string { 126 | const variableNumberMap = getVariablesMap(msgid); 127 | 128 | if (!variableNumberMap) { 129 | return msgid; 130 | } 131 | return replaceVariables(msgid, variableNumberMap); 132 | } 133 | 134 | function findDuplicatingMsgid(msgids: string[], transformedMsgid: string) { 135 | return msgids.find(msgid => { 136 | const variableNumberMap = getVariablesMap(msgid); 137 | 138 | if (!variableNumberMap) { 139 | return false; 140 | } 141 | return replaceVariables(msgid, variableNumberMap) === transformedMsgid; 142 | }); 143 | } 144 | 145 | export function checkDuplicateKeys(poData: PoData) { 146 | const ctxKeys = getObjectKeys(poData.translations); 147 | for (let i = 0; i < ctxKeys.length; i++) { 148 | const ctx = ctxKeys[i]; 149 | const transformedMsgids: Set = new Set(); 150 | const msgids = getObjectKeys(poData.translations[ctx]); 151 | for (let j = 0; j < msgids.length; j++) { 152 | const msgid = msgids[j]; 153 | const newMsgid = transformCompactTranslate(msgid); // msgid where vars replaced by number t`test ${num1}` => t`test ${0}` 154 | if (transformedMsgids.has(newMsgid)) { 155 | const duplicatedMsgid = findDuplicatingMsgid(msgids, newMsgid); 156 | 157 | return ( 158 | `Duplicate msgid ("${msgid}" and "${duplicatedMsgid}" in the same context will be interpreted as the same key "${newMsgid}") this potentially can lead to translation loss.` + 159 | " Consider using deferent context for one of those msgid's. See the context doc here - https://ttag.js.org/docs/context.html" 160 | ); 161 | } 162 | transformedMsgids.add(newMsgid); 163 | } 164 | } 165 | return null; 166 | } 167 | -------------------------------------------------------------------------------- /src/lib/extract.ts: -------------------------------------------------------------------------------- 1 | import "../declarations"; 2 | import * as babel from "@babel/core"; 3 | import * as fs from "fs"; 4 | import * as tmp from "tmp"; 5 | import { extname } from "path"; 6 | import { parseComponent } from "vue-sfc-parser"; 7 | import { walk } from "estree-walker"; 8 | import { parse as parseSvelte } from "svelte/compiler"; 9 | import ignore from "ignore"; 10 | import { TemplateNode } from "svelte/types/compiler/interfaces"; 11 | import { makeBabelConf } from "../defaults"; 12 | import * as ttagTypes from "../types"; 13 | import { TransformFn, pathsWalk } from "./pathsWalk"; 14 | import { mergeOpts } from "./ttagPluginOverride"; 15 | 16 | export async function extractAll( 17 | paths: string[], 18 | lang: string, 19 | progress: ttagTypes.Progress, 20 | overrideOpts?: ttagTypes.TtagOpts, 21 | rcOpts?: ttagTypes.TtagRc 22 | ): Promise { 23 | const tmpFile = tmp.fileSync(); 24 | let ttagOpts: ttagTypes.TtagOpts = { 25 | extract: { output: tmpFile.name }, 26 | sortByMsgid: overrideOpts && overrideOpts.sortByMsgid, 27 | addComments: true 28 | }; 29 | if (lang !== "en") { 30 | ttagOpts.defaultLang = lang; 31 | } 32 | if (overrideOpts) { 33 | ttagOpts = mergeOpts(ttagOpts, overrideOpts); 34 | } 35 | const babelOptions = makeBabelConf(ttagOpts); 36 | const transformFn: TransformFn = filepath => { 37 | try { 38 | switch (extname(filepath)) { 39 | case ".vue": { 40 | const source = fs.readFileSync(filepath).toString(); 41 | const script = parseComponent(source).script; 42 | if (script) { 43 | const lineCount = 44 | source.slice(0, script.start).split(/\r\n|\r|\n/) 45 | .length - 1; 46 | babel.transformSync( 47 | "\n".repeat(lineCount) + script.content, 48 | { 49 | filename: filepath, 50 | ...babelOptions 51 | } 52 | ); 53 | } 54 | break; 55 | } 56 | case ".svelte": { 57 | const source = fs.readFileSync(filepath).toString(); 58 | const jsCodes: string[] = []; 59 | const { html, instance, module } = parseSvelte(source); 60 | 61 | // 5 | 6 | 13 | 14 | 19 | 20 |

{t`Hello ${translated} !`}

21 |

{@html t`Click here for more info`}

22 | 23 | 30 | 31 | {#if loggedIn} 32 | 33 | {/if} 34 | -------------------------------------------------------------------------------- /tests/fixtures/translateTest/translate.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | #: tests/fixtures/checkTest/check-trans-exist.js:2 7 | #: tests/fixtures/checkTest/check-trans-exist.js:3 8 | msgid "test" 9 | msgstr "" 10 | 11 | msgid "test_plural" 12 | msgid_plural "test_plural" 13 | msgstr[0] "" 14 | msgstr[1] "" 15 | -------------------------------------------------------------------------------- /tests/fixtures/tsConstEnum.ts: -------------------------------------------------------------------------------- 1 | import { t } from "ttag"; 2 | 3 | export const enum eTestEnum { 4 | Test = 'test', 5 | } 6 | 7 | export function test(a: number): string { 8 | return t`${a} string ${eTestEnum.Test}`; 9 | } 10 | -------------------------------------------------------------------------------- /tests/fixtures/tsNullishCoalescing.ts: -------------------------------------------------------------------------------- 1 | import { t } from "ttag"; 2 | 3 | function test(data) { 4 | const name = data ?? 'default'; 5 | console.log(t`test ${name}`); 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixtures/tsOptionalChaning.ts: -------------------------------------------------------------------------------- 1 | import { t } from "ttag"; 2 | 3 | function test(data) { 4 | const name = data?.field; 5 | console.log(t`test ${name}`); 6 | } 7 | -------------------------------------------------------------------------------- /tests/fixtures/ukLocaleTest/index.js: -------------------------------------------------------------------------------- 1 | import { ngettext, msgid } from 'ttag'; 2 | 3 | const n = 5; 4 | 5 | ngettext(msgid`${n} банан`, `${n} банана`, `${n} бананів`, n); -------------------------------------------------------------------------------- /tests/fixtures/updateTest/comments.jsx: -------------------------------------------------------------------------------- 1 | import { jt , t } from "ttag"; 2 | 3 | // translator: test comment 4 | t`test` 5 | 6 | const Component = () => { 7 | return {/* translator: jsx test comment */ jt`jsx test`} 8 | } -------------------------------------------------------------------------------- /tests/fixtures/updateTest/context.jsx: -------------------------------------------------------------------------------- 1 | import { c } from "ttag"; 2 | 3 | c('email').t`context translation` -------------------------------------------------------------------------------- /tests/fixtures/updateTest/hoisting.js: -------------------------------------------------------------------------------- 1 | import { t } from 'ttag'; 2 | 3 | if (a) { 4 | const days = 1; 5 | } 6 | 7 | if (b) { 8 | const days = 2; 9 | t`test ${days}`; 10 | } 11 | -------------------------------------------------------------------------------- /tests/fixtures/updateTest/test.js: -------------------------------------------------------------------------------- 1 | import { t, ngettext, msgid } from 'ttag'; 2 | 3 | t`test`; 4 | t`new`; 5 | 6 | ngettext(msgid`${ n } banana`, `${n} bananas`, n); 7 | 8 | _('discover _ test'); 9 | -------------------------------------------------------------------------------- /tests/fixtures/validateTest/validate.po: -------------------------------------------------------------------------------- 1 | msgid "" 2 | msgstr "" 3 | "Content-Type: text/plain; charset=utf-8\n" 4 | "Plural-Forms: nplurals=2; plural=(n!=1);\n" 5 | 6 | #: tests/fixtures/checkTest/check-trans-exist.js:2 7 | msgid "test_empty" 8 | msgstr "" 9 | 10 | msgctxt "xxx" 11 | msgid "test_broken ${y}_fuzzy_no_ref${x(13)}" 12 | msgstr "tail${y} ${x(12) + 12} ${z}" 13 | 14 | #: tests/fixtures/testset.js:2 15 | msgid "test_no_interpolation" 16 | msgstr "xxxx" 17 | 18 | #: tests/fixtures/testset.js:2 19 | msgid "test_correct_interpolation ${x} ${y()} ${z + 5}" 20 | msgstr "test_correct_interpolation ${x} ${y()} ${z + 5}" 21 | 22 | #: tests/fixtures/testset.js:2 23 | msgid "test_broken ${foo.bar}" 24 | msgstr "wrong_interpolation ${bar.foo}" 25 | -------------------------------------------------------------------------------- /tests/fixtures/vueTest/testVueParse.vue: -------------------------------------------------------------------------------- 1 | 4 | 15 | -------------------------------------------------------------------------------- /tests/fixtures/vueTest/testVueWithTagInScript.vue: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /tests/lib/test_merge.ts: -------------------------------------------------------------------------------- 1 | import { mergePo } from "../../src/lib/merge"; 2 | import { PoData } from "../../src/lib/parser"; 3 | 4 | test("mergePo. Should merge with default config", () => { 5 | const poData1: PoData = { 6 | headers: { 7 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 8 | }, 9 | translations: { 10 | "": { 11 | test: { 12 | msgid: "test", 13 | comments: {}, 14 | msgstr: ["test trans"] 15 | } 16 | } 17 | } 18 | }; 19 | 20 | const poData2: PoData = { 21 | headers: { 22 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 23 | }, 24 | translations: { 25 | "": { 26 | test2: { 27 | msgid: "test2", 28 | comments: {}, 29 | msgstr: ["test2 trans"] 30 | } 31 | } 32 | } 33 | }; 34 | 35 | const resultPo = mergePo(poData1, poData2); 36 | expect(resultPo.translations[""]).toHaveProperty("test"); 37 | expect(resultPo.translations[""]).toHaveProperty("test2"); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/lib/test_update.ts: -------------------------------------------------------------------------------- 1 | import { updatePo } from "../../src/lib/update"; 2 | import { PoData } from "../../src/lib/parser"; 3 | 4 | test("updatePo. Should add new message", () => { 5 | const pot: PoData = { 6 | headers: { 7 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 8 | }, 9 | translations: { 10 | "": { 11 | test: { 12 | msgid: "test", 13 | comments: {}, 14 | msgstr: [""] 15 | } 16 | } 17 | } 18 | }; 19 | 20 | const po: PoData = { 21 | headers: { 22 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 23 | }, 24 | translations: { 25 | "": {} 26 | } 27 | }; 28 | 29 | const resultPo = updatePo(pot, po); 30 | expect(resultPo.translations[""]).toHaveProperty("test"); 31 | }); 32 | 33 | test("updatePo. Should update existing", () => { 34 | const pot: PoData = { 35 | headers: { 36 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 37 | }, 38 | translations: { 39 | "": { 40 | test: { 41 | msgid: "test", 42 | comments: { 43 | reference: "path.js:2" 44 | }, 45 | msgstr: [""] 46 | } 47 | } 48 | } 49 | }; 50 | 51 | const po: PoData = { 52 | headers: { 53 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 54 | }, 55 | translations: { 56 | "": { 57 | test: { 58 | msgid: "test", 59 | comments: { 60 | reference: "path.js:1" 61 | }, 62 | msgstr: ["test trans"] 63 | } 64 | } 65 | } 66 | }; 67 | 68 | const resultPo = updatePo(pot, po); 69 | expect(Object.keys(resultPo.translations[""]).length).toBe(1); 70 | expect(resultPo.translations[""]).toHaveProperty("test"); 71 | expect(resultPo.translations[""]["test"].msgstr).toEqual(["test trans"]); 72 | expect(resultPo.translations[""]["test"].comments).toEqual({ 73 | reference: "path.js:2" 74 | }); 75 | }); 76 | 77 | test("updatePo. Should remove obsolete messages", () => { 78 | const pot: PoData = { 79 | headers: { 80 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 81 | }, 82 | translations: { 83 | "": { 84 | test: { 85 | msgid: "test", 86 | comments: { 87 | reference: "path.js:2" 88 | }, 89 | msgstr: [""] 90 | } 91 | } 92 | } 93 | }; 94 | 95 | const po: PoData = { 96 | headers: { 97 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 98 | }, 99 | translations: { 100 | "": { 101 | test: { 102 | msgid: "test", 103 | comments: { 104 | reference: "path.js:1" 105 | }, 106 | msgstr: ["test trans"] 107 | }, 108 | old: { 109 | msgid: "old", 110 | comments: { 111 | reference: "path.js:10" 112 | }, 113 | msgstr: ["old trans"] 114 | } 115 | } 116 | } 117 | }; 118 | 119 | const resultPo = updatePo(pot, po); 120 | expect(Object.keys(resultPo.translations[""]).length).toBe(1); 121 | expect(resultPo.translations[""]).toHaveProperty("test"); 122 | expect(resultPo.translations[""]).not.toHaveProperty("old"); 123 | }); 124 | 125 | test("updatePo. Should not overwrite headers", () => { 126 | const pot: PoData = { 127 | headers: { 128 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 129 | }, 130 | translations: { 131 | "": { 132 | "": { 133 | msgid: "", 134 | msgstr: ["header_pot"] 135 | }, 136 | test: { 137 | msgid: "test", 138 | comments: { 139 | reference: "path.js:2" 140 | }, 141 | msgstr: [""] 142 | } 143 | } 144 | } 145 | }; 146 | 147 | const po: PoData = { 148 | headers: { 149 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 150 | }, 151 | translations: { 152 | "": { 153 | "": { 154 | msgid: "", 155 | msgstr: ["header_po"] 156 | }, 157 | test: { 158 | msgid: "test", 159 | comments: { 160 | reference: "path.js:1" 161 | }, 162 | msgstr: ["test trans"] 163 | } 164 | } 165 | } 166 | }; 167 | 168 | const resultPo = updatePo(pot, po); 169 | expect(resultPo.translations[""][""].msgstr).toEqual(["header_po"]); 170 | }); 171 | 172 | test("updatePo. Should use appropriate number of plural forms", () => { 173 | const pot: PoData = { 174 | headers: { 175 | "plural-forms": "nplurals=2; plural=(n!=1);\n" 176 | }, 177 | translations: { 178 | "": { 179 | banana: { 180 | msgid: "banana", 181 | msgid_plural: "bananas", 182 | msgstr: ["", ""] 183 | } 184 | } 185 | } 186 | }; 187 | 188 | const po: PoData = { 189 | headers: { 190 | "plural-forms": 191 | "nplurals = 3; plural = (n % 10 === 1 && n % 100 !== 11 ? 0 : " + 192 | "n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? 1 : 2);" 193 | }, 194 | translations: { 195 | "": {} 196 | } 197 | }; 198 | 199 | const resultPo = updatePo(pot, po); 200 | expect(resultPo.translations[""]["banana"].msgstr).toEqual(["", "", ""]); 201 | }); 202 | -------------------------------------------------------------------------------- /tests/lib/test_utils.ts: -------------------------------------------------------------------------------- 1 | import { convert2Compact } from "../../src/lib/utils"; 2 | 3 | describe("convert2Compact", () => { 4 | test("should strip headers except of plural-forms and language", () => { 5 | const verbose = { 6 | headers: { 7 | "plural-forms": "nplurals=2; plural=(n!=1);\n", 8 | other: "header", 9 | language: "en" 10 | }, 11 | translations: { 12 | "": {} 13 | } 14 | }; 15 | const result = convert2Compact(verbose); 16 | expect(result.headers).not.toHaveProperty("other"); 17 | expect(result.headers).toHaveProperty( 18 | "plural-forms", 19 | "nplurals=2; plural=(n!=1);\n" 20 | ); 21 | expect(result.headers).toHaveProperty("language", "en"); 22 | }); 23 | test("should omit the empty string translation", () => { 24 | const verbose = { 25 | headers: { 26 | "plural-forms": "nplurals=2; plural=(n!=1);\n", 27 | other: "header" 28 | }, 29 | translations: { 30 | "": { 31 | "": { 32 | msgid: "", 33 | msgstr: [ 34 | "Content-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nProject-Id-Version: protonmail\nPlural-Forms: nplurals=2; plural=(n != 1);\nX-Generator: crowdin.com\nX-Crowdin-Project: protonmail\nX-Crowdin-Language: pt-BR\nX-Crowdin-File: ProtonMail Web Application.pot\nLast-Translator: PMtranslator\nLanguage-Team: Portuguese, Brazilian\nLanguage: pt_BR\nPO-Revision-Date: 2019-04-17 08:44\n" 35 | ] 36 | } 37 | } 38 | } 39 | }; 40 | const result = convert2Compact(verbose); 41 | expect(result.contexts[""]).not.toHaveProperty(""); 42 | }); 43 | test("should transform poEntry", () => { 44 | const verbose = { 45 | headers: { 46 | "plural-forms": "nplurals=2; plural=(n!=1);\n", 47 | other: "header" 48 | }, 49 | translations: { 50 | "": { 51 | test: { 52 | msgid: "test", 53 | msgstr: ["test [translation]"] 54 | } 55 | } 56 | } 57 | }; 58 | const result = convert2Compact(verbose); 59 | expect(result.contexts[""]).toHaveProperty("test", [ 60 | "test [translation]" 61 | ]); 62 | }); 63 | test("should apply all contexts", () => { 64 | const verbose = { 65 | headers: { 66 | "plural-forms": "nplurals=2; plural=(n!=1);\n", 67 | other: "header" 68 | }, 69 | translations: { 70 | "": { 71 | test: { 72 | msgid: "test", 73 | msgstr: ["test [translation]"] 74 | } 75 | }, 76 | ctx: { 77 | "ctx test": { 78 | msgid: "ctx test", 79 | msgstr: ["ctx test [translation]"] 80 | } 81 | } 82 | } 83 | }; 84 | const result = convert2Compact(verbose); 85 | expect(result.contexts[""]).toHaveProperty("test", [ 86 | "test [translation]" 87 | ]); 88 | expect(result.contexts["ctx"]).toHaveProperty("ctx test", [ 89 | "ctx test [translation]" 90 | ]); 91 | }); 92 | test("should remove untranslated and fuzzy", () => { 93 | const verbose = { 94 | headers: { 95 | "plural-forms": "nplurals=2; plural=(n!=1);\n", 96 | other: "header" 97 | }, 98 | translations: { 99 | "": { 100 | untranslated: { 101 | msgid: "test", 102 | msgstr: [] 103 | }, 104 | fuzzy: { 105 | msgid: "test", 106 | msgstr: [], 107 | comments: { 108 | flag: "fuzzy" 109 | } 110 | }, 111 | test: { 112 | msgid: "test", 113 | msgstr: ["test [translation]"] 114 | } 115 | } 116 | } 117 | }; 118 | const result = convert2Compact(verbose); 119 | expect(result.contexts[""]).toHaveProperty("test"); 120 | expect(result.contexts[""]).not.toHaveProperty("untranslated"); 121 | expect(result.contexts[""]).not.toHaveProperty("fuzzy"); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "outDir": "dist", 5 | "lib":["es5", "es6", "es2017", "dom"], 6 | "strictNullChecks": true, 7 | "noImplicitAny": true, 8 | "noImplicitReturns": true, 9 | "noUnusedParameters": true, 10 | "noUnusedLocals": true, 11 | "downlevelIteration":true, 12 | "baseUrl": ".", 13 | "pretty": true, 14 | "paths": { 15 | "*": ["./node_modules/@types/*", "*"] 16 | } 17 | }, 18 | "exclude": [ 19 | "tests/fixtures", 20 | "node_modules" 21 | ] 22 | } --------------------------------------------------------------------------------