From 743e5aa521ce917581b3c3430c67cd2cca28e7b8 Mon Sep 17 00:00:00 2001 From: Frederik Ring Date: Fri, 17 Jul 2020 12:19:22 +0200 Subject: [PATCH] add package content --- .circleci/config.yml | 19 ++++++++ .editorconfig | 18 ++++++++ .gitignore | 2 + README.md | 87 ++++++++++++++++++++++++++++++++++++ bin/cmd.js | 77 ++++++++++++++++++++++++++++++++ extract.js | 104 +++++++++++++++++++++++++++++++++++++++++++ index.js | 98 ++++++++++++++++++++++++++++++++++++++++ package.json | 44 ++++++++++++++++++ 8 files changed, 449 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .editorconfig create mode 100755 bin/cmd.js create mode 100644 extract.js create mode 100644 index.js create mode 100644 package.json diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..5e34377 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,19 @@ +version: 2.1 + +jobs: + test: + executor: + name: node/default + steps: + - checkout + - node/with-cache: + steps: + - run: npm install + - run: npm test +workflows: + test: + jobs: + - test + +orbs: + node: circleci/node@1.1.6 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd0895c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# Copyright 2020 - Offen Authors +# SPDX-License-Identifier: Apache-2.0 + +# EditorConfig is awesome: http://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 6704566..cab290e 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +package-lock.json diff --git a/README.md b/README.md index 48d8ae2..20ac37f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,89 @@ # l10nify + Localization workflow for Browserify + +## How does this work + +This is a Browserify-based workflow for localizing client side applications. It is built for use cases where you can and want to ship one bundle per locale. Strings are defined by calling a pseudo-global function (i.e. `__(string, args..)`) in the default language in your code (similar to GNU gettext or similar). Strings are stored in `.po` files. + +## Installation + +Install from npm: + +``` +npm install @offen/l10nify +``` + +This installs two things: + +- a Browserify transform for localizing strings at bundle time. It is references as `@offen/l10nify` +- a `extract-strings` command that you can use to generate PO files from your JavaScript code. + +## Usage + +### Defining strings in client side code + +In your code, use the `__(string, args...)` function (`__` is the default, but you can use anything) to declare strings in your default language (which defaults to `en` but can be anything you want it to): + +```js +const HappyBirthdayComponent = (props) => { + return ( +

{__('Happy birthday, %s!', props.name)}

+ ) +} +``` + +### Extract strings from your code + +Next, you can extract these strings from your code into `.po` files using the `extract-strings` command: + +``` +$(npm bin)/extract-strings --default-locale en \ + --locale es \ + --locale fr \ + --locale de \ + **/*.js +``` + +This will create a `.po` file for each non-default locale in a directory called `./locales` (pass `--target` if you want to change this). + +Refer to `extract-strings --help` for a full list of options + +### Apply the transform at bundle time + +Apply the transform to your Browserify setup passing the target `locale`. In development, you can omit this parameter to make the transform return the default locale, i.e. the strings you defined in code. + +```js +var browserify = require('browserify') + +var b = browserify() +b.add('app.js') +b.transform('@offen/l10nify', { locale: 'fr' }) +b.bundle(function (err, src) { + // consume bundle +}) +``` + +#### Options + +The following options can be passed to the transform: + +##### `locale` + +`locale` specifies the locale which you want to return when bundling. It defaults to `en`. + +##### `defaultLocale` + +`defaultLocale` specifies the default locale that is used to define strings in code. It defaults to `en`. + +##### `source` + +`source` specifies the directory in which the `.po` files are stored. It defaults to `./locales` + +##### `global` + +`global` defines the global function identifier that is used for defining strings in code. It defaults to `__`. + +## License + +Copyright 2020 Frederik Ring - `l10nify` is available under the MIT License diff --git a/bin/cmd.js b/bin/cmd.js new file mode 100755 index 0000000..3572840 --- /dev/null +++ b/bin/cmd.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +/** + * Copyright 2020 - Frederik Ring + * SPDX-License-Identifier: MIT + */ + +var path = require('path') +var arg = require('arg') + +var extractStrings = require('./../extract.js') + +var args = arg({ + '--help': Boolean, + '--default-locale': String, + '--locale': [String], + '--target': String, + '--match': String, + '-h': '--help', + '-d': '--default-locale', + '-l': '--locale', + '-t': '--target', + '-m': '--match' +}) + +if (args['--help']) { + console.log(`Usage: extract-strings [options] +Options: + -l, --locale [LOCALE] Specify the locales to extract. Pass multiple + locales by passing multiple flags. + -d, --default-locale [LOCALE] Specify the default locale that is used in code. + Defaults to "en". + -t, --target [DIRECTORY] Specify the target directory for saving .po + files. Defaults to "./locales". + -m, --match [DIRECTORY] Specify a glob pattern for files to extract + strings from. Defaults to "**/*.js". + -g, --global [IDENTIFIER] Specify the global identifier used as l10n + function in code, Defaults to "__" + -h, --help Display this help message. +`) + process.exit(0) +} + +args = Object.assign({ + '--default-locale': 'en', + '--locale': [], + '--target': './locales/', + '--match': '**/*.js', + '--global': '__' +}, args) + +var eligible = args['--locale'] + .filter(function (locale) { + return locale !== args['--default-locale'] + }) + +if (eligible.length === 0) { + console.log('No non-default locales were configured. Nothing to do.') + console.log('If this is unintended, check the locales passed to this task.') + process.exit(0) +} + +Promise.all(eligible.map(function (locale) { + return extractStrings( + path.join(process.cwd(), args['--target'], locale + '.po'), + args['--match'], + args['--global'] + ) +})) + .then(() => { + console.log('Successfully extracted %d locales.', eligible.length) + }) + .catch(function (err) { + console.error('Error extracting strings: %s', err.message) + console.error(err) + process.exit(1) + }) diff --git a/extract.js b/extract.js new file mode 100644 index 0000000..6020058 --- /dev/null +++ b/extract.js @@ -0,0 +1,104 @@ +/** + * Copyright 2020 - Frederik Ring + * SPDX-License-Identifier: MIT + */ + +var fs = require('fs') +var util = require('util') +var jscodeshift = require('jscodeshift') +var glob = require('glob') +var touch = require('touch') +var PO = require('pofile') + +module.exports = extractStrings + +function extractStrings (destination, globPattern, globalFunctionIdentifier) { + return util.promisify(glob)(globPattern) + .then(function (files) { + if (!files || !files.length) { + console.log('No files found using given pattern %s, exiting', globPattern) + return null + } + return parse(files, globalFunctionIdentifier) + }) + .then(function (allStrings) { + return merge(destination, allStrings) + }) + .then(function () { + console.log('Successfully saved strings to %j', destination) + }) +} + +function merge (file, allStrings) { + var currentPo = new PO() + currentPo.items = allStrings + + return util.promisify(PO.load)(file) + .catch(function (err) { + if (err.code === 'ENOENT') { + return null + } + throw err + }) + .then(function (existingPo) { + if (existingPo) { + currentPo.items = currentPo.items.map(function (item) { + var exists = existingPo.items.filter(function (existingItem) { + return existingItem.msgid === item.msgid + }) + if (exists.length && exists[0].msgstr.length) { + item.msgstr = exists[0].msgstr + } + return item + }) + } + return util.promisify(touch)(file) + }) + .then(function () { + return util.promisify(currentPo.save).bind(currentPo)(file) + }) +} + +function parse (files, globalFunctionIdentifier) { + var all = files + .filter(function (fileName) { + return !(/node_modules/.test(fileName)) + }) + .map(function (fileName) { + return new Promise(function (resolve, reject) { + fs.readFile(fileName, 'utf-8', function (err, data) { + if (err) { + return reject(err) + } + var j = jscodeshift(data) + var calls = j.find(jscodeshift.CallExpression, { + callee: { + type: 'Identifier', + name: globalFunctionIdentifier + } + }) + var strings = [] + calls.forEach(function (node) { + var dupes = strings.filter(function (string) { + return string.msgid === node.value.arguments[0].value + }) + if (dupes.length) { + dupes[0].comments.push(fileName + ':' + node.node.loc.start.line) + } else { + var item = new PO.Item() + item.msgid = node.value.arguments[0].value + item.comments = [ + fileName + ':' + node.node.loc.start.line + ] + strings.push(item) + } + }) + resolve(strings) + }) + }) + }) + return Promise.all(all) + .then(function (results) { + return [].concat.apply([], results) + }) +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..0331b24 --- /dev/null +++ b/index.js @@ -0,0 +1,98 @@ +/** + * Copyright 2020 - Frederik Ring + * SPDX-License-Identifier: MIT + */ + +var path = require('path') +var through = require('through2') +var jscodeshift = require('jscodeshift') +var PO = require('pofile') + +module.exports = transform + +function transform (file, options) { + var locale = options.locale || 'en' + var defaultLocale = options.defaultLocale || 'en' + var globalFunctionIdentifier = options.global || '__' + var source = options.source || './locales/' + + var buf = '' + return through(function (chunk, enc, next) { + buf += chunk.toString('utf-8') + next() + }, function (done) { + var self = this + inlineStrings(buf, locale, defaultLocale, source, globalFunctionIdentifier, function (err, result) { + if (err) { + return done(err) + } + self.push(result) + done() + }) + }) +} + +function inlineStrings (sourceString, locale, defaultLocale, source, globalFunctionIdentifier, callback) { + getStringMap(locale, defaultLocale, source, function (err, stringMap) { + if (err) { + return callback(err) + } + + var j = jscodeshift(sourceString) + var calls = j.find(jscodeshift.CallExpression, { + callee: { + type: 'Identifier', + name: globalFunctionIdentifier + } + }) + + calls.replaceWith(function (node) { + if (node.value.arguments.length === 0) { + return node + } + + var formatStr = locale === defaultLocale + ? node.value.arguments[0].value + : stringMap[node.value.arguments[0].value] || node.value.arguments[0].value + // one arguments means the call can just be replaced by its + // string counterpart + if (node.value.arguments.length === 1) { + return jscodeshift.stringLiteral(formatStr) + } + + // more than one argument means we want to do string interpolation + var args = node.value.arguments.slice(1) + + return jscodeshift.callExpression( + jscodeshift.memberExpression( + jscodeshift.callExpression( + jscodeshift.identifier('require'), + [ + jscodeshift.stringLiteral('util') + ] + ), + jscodeshift.identifier('format') + ), + [jscodeshift.stringLiteral(formatStr)].concat(args) + ) + }) + callback(null, j.toSource()) + }) +} + +function getStringMap (locale, defaultLocale, source, callback) { + if (locale === defaultLocale) { + return callback(null, {}) + } + var sourceFile = path.join(process.cwd(), source, locale + '.po') + PO.load(sourceFile, function (err, data) { + if (err) { + return callback(err) + } + var stringMap = data.items.reduce(function (acc, next) { + acc[next.msgid] = next.msgstr[0] + return acc + }, {}) + callback(null, stringMap) + }) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb6366b --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "@offen/l10nify", + "version": "0.0.0", + "description": "Localization workflow for Browserify", + "main": "index.js", + "scripts": { + "pretest": "standard", + "test": "echo \"Error: no test specified\" && exit 1", + "preversion": "npm test", + "version": "changes --commits", + "postversion": "git push --follow-tags && npm publish --access public" + }, + "bin": { + "extract-strings": "./bin/cli.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/offen/l10nify.git" + }, + "keywords": [ + "browserify", + "l10", + "localization" + ], + "author": "Frederik Ring ", + "license": "MIT", + "bugs": { + "url": "https://github.com/offen/l10nify/issues" + }, + "homepage": "https://github.com/offen/l10nify#readme", + "devDependencies": { + "@studio/changes": "^2.0.1", + "standard": "^14.3.4", + "tape": "^5.0.1" + }, + "dependencies": { + "arg": "^4.1.3", + "glob": "^7.1.6", + "jscodeshift": "^0.10.0", + "pofile": "^1.1.0", + "through2": "^4.0.2", + "touch": "^3.1.0" + } +}