diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5ce8c64 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,21 @@ +{ + "overrides": [ + { + "files": "*.ts", + "parser": "@typescript-eslint/parser", + "rules": { + "no-unused-vars": "off", + "no-useless-constructor": "off", + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/no-useless-constructor": "error" + } + } + ], + "parserOptions": { + "ecmaVersion": 2017, + "sourceType": "module" + }, + "plugins": [ + "@typescript-eslint/eslint-plugin" + ] +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 18714f3..a423c85 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,3 +39,25 @@ jobs: - run: npm install - run: npm test - run: npm run coverage + esm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '14.x' + - run: npm install + - run: npm run test:esm + deno: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 14 + - run: npm install + - run: npm run compile + - uses: denolib/setup-deno@v2 + - run: | + deno --version + deno test --allow-read test/deno/y18n-test.ts diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 38ace4d..2fdb821 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -7,8 +7,38 @@ jobs: release-please: runs-on: ubuntu-latest steps: - - uses: GoogleCloudPlatform/release-please-action@v1.6.3 + - uses: bcoe/release-please-action@v2.0.0 + id: release with: token: ${{ secrets.GITHUB_TOKEN }} release-type: node package-name: y18n + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: 14 + - run: npm install + - run: npm run compile + - name: push Deno release + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + git remote add gh-token "https://${{ secrets.GITHUB_TOKEN}}@github.com/yargs/y18n.git" + git checkout -b deno + git add -f build + git commit -a -m 'chore: ${{ steps.release.outputs.tag_name }} release' + git push origin +deno + git tag -a ${{ steps.release.outputs.tag_name }}-deno -m 'chore: ${{ steps.release.outputs.tag_name }} release' + git push origin ${{ steps.release.outputs.tag_name }}-deno + if: ${{ steps.release.outputs.release_created }} + - uses: actions/setup-node@v1 + with: + node-version: 14 + registry-url: 'https://external-dot-oss-automation.appspot.com/' + if: ${{ steps.release.outputs.release_created }} + - run: npm install + if: ${{ steps.release.outputs.release_created }} + - run: npm publish + env: + NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} + if: ${{ steps.release.outputs.release_created }} diff --git a/.gitignore b/.gitignore index 20bf8fc..6430fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .DS_Store node_modules .nyc_output +build coverage package-lock.json +example.* diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index b009dfb..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -lts/* diff --git a/README.md b/README.md index 2d0b6cc..5102bb1 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,23 @@ output: `2 fishes foo` +## Deno Example + +As of `v5` `y18n` supports [Deno](https://github.com/denoland/deno): + +```typescript +import y18n from "https://deno.land/x/y18n/deno.ts"; + +const __ = y18n({ + locale: 'pirate', + directory: './test/locales' +}).__ + +console.info(__`Hi, ${'Ben'} ${'Coe'}!`) +``` + +You will need to run with `--allow-read` to load alternative locales. + ## JSON Language Files The JSON language files should be stored in a `./locales` folder. diff --git a/deno.ts b/deno.ts new file mode 100644 index 0000000..b743c4f --- /dev/null +++ b/deno.ts @@ -0,0 +1,9 @@ +import { y18n as _y18n } from './build/lib/index.js' +import { Y18NOpts } from './build/lib/index.d.ts' +import shim from './lib/platform-shims/deno.ts' + +const y18n = (opts: Y18NOpts) => { + return _y18n(opts, shim) +} + +export default y18n diff --git a/index.js b/index.js deleted file mode 100644 index d720681..0000000 --- a/index.js +++ /dev/null @@ -1,188 +0,0 @@ -var fs = require('fs') -var path = require('path') -var util = require('util') - -function Y18N (opts) { - // configurable options. - opts = opts || {} - this.directory = opts.directory || './locales' - this.updateFiles = typeof opts.updateFiles === 'boolean' ? opts.updateFiles : true - this.locale = opts.locale || 'en' - this.fallbackToLanguage = typeof opts.fallbackToLanguage === 'boolean' ? opts.fallbackToLanguage : true - - // internal stuff. - this.cache = {} - this.writeQueue = [] -} - -Y18N.prototype.__ = function () { - if (typeof arguments[0] !== 'string') { - return this._taggedLiteral.apply(this, arguments) - } - var args = Array.prototype.slice.call(arguments) - var str = args.shift() - var cb = function () {} // start with noop. - - if (typeof args[args.length - 1] === 'function') cb = args.pop() - cb = cb || function () {} // noop. - - if (!this.cache[this.locale]) this._readLocaleFile() - - // we've observed a new string, update the language file. - if (!this.cache[this.locale][str] && this.updateFiles) { - this.cache[this.locale][str] = str - - // include the current directory and locale, - // since these values could change before the - // write is performed. - this._enqueueWrite([this.directory, this.locale, cb]) - } else { - cb() - } - - return util.format.apply(util, [this.cache[this.locale][str] || str].concat(args)) -} - -Y18N.prototype._taggedLiteral = function (parts) { - var args = arguments - var str = '' - parts.forEach(function (part, i) { - var arg = args[i + 1] - str += part - if (typeof arg !== 'undefined') { - str += '%s' - } - }) - return this.__.apply(null, [str].concat([].slice.call(arguments, 1))) -} - -Y18N.prototype._enqueueWrite = function (work) { - this.writeQueue.push(work) - if (this.writeQueue.length === 1) this._processWriteQueue() -} - -Y18N.prototype._processWriteQueue = function () { - var _this = this - var work = this.writeQueue[0] - - // destructure the enqueued work. - var directory = work[0] - var locale = work[1] - var cb = work[2] - - var languageFile = this._resolveLocaleFile(directory, locale) - var serializedLocale = JSON.stringify(this.cache[locale], null, 2) - - fs.writeFile(languageFile, serializedLocale, 'utf-8', function (err) { - _this.writeQueue.shift() - if (_this.writeQueue.length > 0) _this._processWriteQueue() - cb(err) - }) -} - -Y18N.prototype._readLocaleFile = function () { - var localeLookup = {} - var languageFile = this._resolveLocaleFile(this.directory, this.locale) - - try { - localeLookup = JSON.parse(fs.readFileSync(languageFile, 'utf-8')) - } catch (err) { - if (err instanceof SyntaxError) { - err.message = 'syntax error in ' + languageFile - } - - if (err.code === 'ENOENT') localeLookup = {} - else throw err - } - - this.cache[this.locale] = localeLookup -} - -Y18N.prototype._resolveLocaleFile = function (directory, locale) { - var file = path.resolve(directory, './', locale + '.json') - if (this.fallbackToLanguage && !this._fileExistsSync(file) && ~locale.lastIndexOf('_')) { - // attempt fallback to language only - var languageFile = path.resolve(directory, './', locale.split('_')[0] + '.json') - if (this._fileExistsSync(languageFile)) file = languageFile - } - return file -} - -// this only exists because fs.existsSync() "will be deprecated" -// see https://nodejs.org/api/fs.html#fs_fs_existssync_path -Y18N.prototype._fileExistsSync = function (file) { - try { - return fs.statSync(file).isFile() - } catch (err) { - return false - } -} - -Y18N.prototype.__n = function () { - var args = Array.prototype.slice.call(arguments) - var singular = args.shift() - var plural = args.shift() - var quantity = args.shift() - - var cb = function () {} // start with noop. - if (typeof args[args.length - 1] === 'function') cb = args.pop() - - if (!this.cache[this.locale]) this._readLocaleFile() - - var str = quantity === 1 ? singular : plural - if (this.cache[this.locale][singular]) { - str = this.cache[this.locale][singular][quantity === 1 ? 'one' : 'other'] - } - - // we've observed a new string, update the language file. - if (!this.cache[this.locale][singular] && this.updateFiles) { - this.cache[this.locale][singular] = { - one: singular, - other: plural - } - - // include the current directory and locale, - // since these values could change before the - // write is performed. - this._enqueueWrite([this.directory, this.locale, cb]) - } else { - cb() - } - - // if a %d placeholder is provided, add quantity - // to the arguments expanded by util.format. - var values = [str] - if (~str.indexOf('%d')) values.push(quantity) - - return util.format.apply(util, values.concat(args)) -} - -Y18N.prototype.setLocale = function (locale) { - this.locale = locale -} - -Y18N.prototype.getLocale = function () { - return this.locale -} - -Y18N.prototype.updateLocale = function (obj) { - if (!this.cache[this.locale]) this._readLocaleFile() - - for (var key in obj) { - this.cache[this.locale][key] = obj[key] - } -} - -module.exports = function (opts) { - var y18n = new Y18N(opts) - - // bind all functions to y18n, so that - // they can be used in isolation. - for (var key in y18n) { - if (typeof y18n[key] === 'function') { - y18n[key] = y18n[key].bind(y18n) - } - } - - return y18n -} diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..46c8213 --- /dev/null +++ b/index.mjs @@ -0,0 +1,8 @@ +import shim from './build/lib/platform-shims/node.js' +import { y18n as _y18n } from './build/lib/index.js' + +const y18n = (opts) => { + return _y18n(opts, shim) +} + +export default y18n diff --git a/lib/cjs.ts b/lib/cjs.ts new file mode 100644 index 0000000..1ba6a58 --- /dev/null +++ b/lib/cjs.ts @@ -0,0 +1,8 @@ +import { y18n as _y18n, Y18NOpts } from './index.js' +import nodePlatformShim from './platform-shims/node.js' + +const y18n = (opts: Y18NOpts) => { + return _y18n(opts, nodePlatformShim) +} + +export default y18n diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..864cbac --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,227 @@ +export interface Y18NOpts { + directory?: string; + updateFiles?: boolean; + locale?: string; + fallbackToLanguage?: boolean; +} + +interface Work { + directory: string; + locale: string; + cb: Function +} + +export interface Locale { + [key: string]: string +} + +interface CacheEntry { + [key: string]: string; +} + +export interface PlatformShim { + fs: { + readFileSync: Function, + writeFile: Function + }, + exists: Function, + format: Function, + resolve: Function +} + +let shim: PlatformShim +class Y18N { + directory: string; + updateFiles: boolean; + locale: string; + fallbackToLanguage: boolean; + writeQueue: Work[]; + cache: {[key: string]: {[key: string]: CacheEntry|string}}; + + constructor (opts: Y18NOpts) { + // configurable options. + opts = opts || {} + this.directory = opts.directory || './locales' + this.updateFiles = typeof opts.updateFiles === 'boolean' ? opts.updateFiles : true + this.locale = opts.locale || 'en' + this.fallbackToLanguage = typeof opts.fallbackToLanguage === 'boolean' ? opts.fallbackToLanguage : true + + // internal stuff. + this.cache = {} + this.writeQueue = [] + } + + __ (...args: (string|Function)[]): string { + if (typeof arguments[0] !== 'string') { + return this._taggedLiteral(arguments[0] as string[], ...arguments) + } + const str: string = args.shift() as string + + let cb: Function = function () {} // start with noop. + if (typeof args[args.length - 1] === 'function') cb = (args.pop() as Function) + cb = cb || function () {} // noop. + + if (!this.cache[this.locale]) this._readLocaleFile() + + // we've observed a new string, update the language file. + if (!this.cache[this.locale][str] && this.updateFiles) { + this.cache[this.locale][str] = str + + // include the current directory and locale, + // since these values could change before the + // write is performed. + this._enqueueWrite({ + directory: this.directory, + locale: this.locale, + cb + }) + } else { + cb() + } + + return shim.format.apply(shim.format, [this.cache[this.locale][str] || str].concat(args as string[])) + } + + __n () { + const args = Array.prototype.slice.call(arguments) + const singular: string = args.shift() + const plural: string = args.shift() + const quantity: number = args.shift() + + let cb = function () {} // start with noop. + if (typeof args[args.length - 1] === 'function') cb = args.pop() + + if (!this.cache[this.locale]) this._readLocaleFile() + + let str = quantity === 1 ? singular : plural + if (this.cache[this.locale][singular]) { + const entry = this.cache[this.locale][singular] as CacheEntry + str = entry[quantity === 1 ? 'one' : 'other'] + } + + // we've observed a new string, update the language file. + if (!this.cache[this.locale][singular] && this.updateFiles) { + this.cache[this.locale][singular] = { + one: singular, + other: plural + } + + // include the current directory and locale, + // since these values could change before the + // write is performed. + this._enqueueWrite({ + directory: this.directory, + locale: this.locale, + cb + }) + } else { + cb() + } + + // if a %d placeholder is provided, add quantity + // to the arguments expanded by util.format. + var values: (string|number)[] = [str] + if (~str.indexOf('%d')) values.push(quantity) + + return shim.format.apply(shim.format, values.concat(args)) + } + + setLocale (locale: string) { + this.locale = locale + } + + getLocale () { + return this.locale + } + + updateLocale (obj: Locale) { + if (!this.cache[this.locale]) this._readLocaleFile() + + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + this.cache[this.locale][key] = obj[key] + } + } + } + + _taggedLiteral (parts: string[], ...args: string[]) { + let str = '' + parts.forEach(function (part, i) { + var arg = args[i + 1] + str += part + if (typeof arg !== 'undefined') { + str += '%s' + } + }) + return this.__.apply(this, [str].concat([].slice.call(args, 1))) + } + + _enqueueWrite (work: Work) { + this.writeQueue.push(work) + if (this.writeQueue.length === 1) this._processWriteQueue() + } + + _processWriteQueue () { + var _this = this + var work = this.writeQueue[0] + + // destructure the enqueued work. + var directory = work.directory + var locale = work.locale + var cb = work.cb + + var languageFile = this._resolveLocaleFile(directory, locale) + var serializedLocale = JSON.stringify(this.cache[locale], null, 2) + + shim.fs.writeFile(languageFile, serializedLocale, 'utf-8', function (err: Error) { + _this.writeQueue.shift() + if (_this.writeQueue.length > 0) _this._processWriteQueue() + cb(err) + }) + } + + _readLocaleFile () { + var localeLookup = {} + var languageFile = this._resolveLocaleFile(this.directory, this.locale) + + try { + localeLookup = JSON.parse(shim.fs.readFileSync(languageFile, 'utf-8')) + } catch (err) { + if (err instanceof SyntaxError) { + err.message = 'syntax error in ' + languageFile + } + + if (err.code === 'ENOENT') localeLookup = {} + else throw err + } + + this.cache[this.locale] = localeLookup + } + + _resolveLocaleFile (directory: string, locale: string) { + var file = shim.resolve(directory, './', locale + '.json') + if (this.fallbackToLanguage && !this._fileExistsSync(file) && ~locale.lastIndexOf('_')) { + // attempt fallback to language only + var languageFile = shim.resolve(directory, './', locale.split('_')[0] + '.json') + if (this._fileExistsSync(languageFile)) file = languageFile + } + return file + } + + _fileExistsSync (file: string) { + return shim.exists(file) + } +} + +export function y18n (opts: Y18NOpts, _shim: PlatformShim) { + shim = _shim + const y18n = new Y18N(opts) + return { + __: y18n.__.bind(y18n), + __n: y18n.__n.bind(y18n), + setLocale: y18n.setLocale.bind(y18n), + getLocale: y18n.getLocale.bind(y18n), + updateLocale: y18n.updateLocale.bind(y18n), + locale: y18n.locale + } +} diff --git a/lib/platform-shims/deno.ts b/lib/platform-shims/deno.ts new file mode 100644 index 0000000..3cd243e --- /dev/null +++ b/lib/platform-shims/deno.ts @@ -0,0 +1,34 @@ +/* global Deno */ + +import { posix } from 'https://deno.land/std/path/mod.ts' +import { sprintf } from 'https://deno.land/std/fmt/printf.ts' + +export default { + fs: { + readFileSync: (path: string) => { + try { + return Deno.readTextFileSync(path) + } catch (err) { + // Fake the same error as Node.js, so that it does not bubble. + err.code = 'ENOENT' + throw err + } + }, + writeFile: Deno.writeFile + }, + format: sprintf, + resolve: (base: string, p1: string, p2: string) => { + try { + return posix.resolve(base, p1, p2) + } catch (err) { + // Most likely we simply don't have --allow-read set. + } + }, + exists: (file: string) => { + try { + return Deno.statSync(file).isFile + } catch (err) { + return false + } + } +} diff --git a/lib/platform-shims/node.ts b/lib/platform-shims/node.ts new file mode 100644 index 0000000..2d40e7b --- /dev/null +++ b/lib/platform-shims/node.ts @@ -0,0 +1,19 @@ +import { readFileSync, statSync, writeFile } from 'fs' +import { format } from 'util' +import { resolve } from 'path' + +export default { + fs: { + readFileSync, + writeFile + }, + format, + resolve, + exists: (file: string) => { + try { + return statSync(file).isFile() + } catch (err) { + return false + } + } +} diff --git a/package.json b/package.json index ad989b6..df42208 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,13 @@ "name": "y18n", "version": "4.0.0", "description": "the bare-bones internationalization library used by yargs", + "exports": { + "import": "./index.mjs", + "require": "./build/index.cjs" + }, + "type": "module", + "module": "./build/lib/index.js", + "types": "./build/index.cjs.d.ts", "keywords": [ "i18n", "internationalization", @@ -18,23 +25,45 @@ "license": "ISC", "author": "Ben Coe ", "main": "index.js", - "files": [ - "index.js" - ], "scripts": { - "fix": "standard --fix", - "coverage": "c8 --check-coverage report", - "pretest": "standard", - "test": "c8 mocha" + "check": "standardx '**/*.ts' '**/*.cjs' '**/*.mjs'", + "fix": "standardx --fix '**/*.ts' '**/*.cjs' '**/*.mjs'", + "pretest": "rimraf build && tsc -p tsconfig.test.json && cross-env NODE_ENV=test npm run build:cjs", + "test": "c8 --reporter=text --reporter=html mocha test/*.cjs", + "test:esm": "c8 --reporter=text --reporter=html mocha test/esm/*.mjs", + "posttest": "npm run check", + "coverage": "c8 report --check-coverage", + "precompile": "rimraf build", + "compile": "tsc", + "postcompile": "npm run build:cjs", + "build:cjs": "rollup -c", + "prepare": "npm run compile" }, "devDependencies": { + "@types/node": "^14.6.4", + "@wessberg/rollup-plugin-ts": "^1.3.1", "c8": "^7.3.0", "chai": "^4.0.1", + "cross-env": "^7.0.2", + "gts": "^2.0.2", "mocha": "^8.0.0", "rimraf": "^3.0.2", - "standard": "^14.3.4" + "rollup": "^2.26.10", + "standardx": "^5.0.0", + "ts-transform-default-export": "^1.0.2", + "typescript": "^3.9.7" }, + "files": [ + "build", + "index.mjs", + "!*.d.ts" + ], "engines": { "node": ">=10" + }, + "standardx": { + "ignore": [ + "build" + ] } } diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..537ce1e --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,22 @@ +import ts from '@wessberg/rollup-plugin-ts' +import transformDefaultExport from 'ts-transform-default-export' + +const output = { + format: 'cjs', + file: './build/index.cjs', + exports: 'default' +} + +if (process.env.NODE_ENV === 'test') output.sourcemap = true + +export default { + input: './lib/cjs.ts', + output, + plugins: [ + ts({ + transformers: ({ program }) => ({ + afterDeclarations: transformDefaultExport(program) + }) + }), + ] +} diff --git a/test/deno/y18n-test.ts b/test/deno/y18n-test.ts new file mode 100644 index 0000000..9b1b497 --- /dev/null +++ b/test/deno/y18n-test.ts @@ -0,0 +1,19 @@ +/* global Deno */ + +import { + assertEquals +} from 'https://deno.land/std/testing/asserts.ts' +import y18n from '../../deno.ts' + +// Parser: +Deno.test('smoke test', () => { + const __ = y18n({ + locale: 'pirate', + directory: './test/locales' + }).__ + + assertEquals( + __`Hi, ${'Ben'} ${'Coe'}!`, + 'Yarr! Shiver me timbers, why \'tis Ben Coe!' + ) +}) diff --git a/test/esm/y18n-test.mjs b/test/esm/y18n-test.mjs new file mode 100644 index 0000000..c8675ae --- /dev/null +++ b/test/esm/y18n-test.mjs @@ -0,0 +1,18 @@ +/* global describe, it */ + +import * as assert from 'assert' +import y18n from '../../index.mjs' + +describe('y18n', function () { + it('__ smoke test', function () { + const __ = y18n({ + locale: 'pirate', + directory: './test/locales' + }).__ + + assert.strictEqual( + __`Hi, ${'Ben'} ${'Coe'}!`, + 'Yarr! Shiver me timbers, why \'tis Ben Coe!' + ) + }) +}) diff --git a/test/y18n-test.js b/test/y18n-test.cjs similarity index 98% rename from test/y18n-test.js rename to test/y18n-test.cjs index e96e8e6..d65b14b 100644 --- a/test/y18n-test.js +++ b/test/y18n-test.cjs @@ -1,17 +1,17 @@ /* global describe, it, after, beforeEach */ -var expect = require('chai').expect -var fs = require('fs') -var rimraf = require('rimraf') -var y18n = require('../') -var path = require('path') +const expect = require('chai').expect +const fs = require('fs') +const rimraf = require('rimraf') +const y18n = require('../build/index.cjs') +const path = require('path') require('chai').should() describe('y18n', function () { describe('configure', function () { it('allows you to override the default y18n configuration', function () { - var y = y18n({ locale: 'fr' }) + const y = y18n({ locale: 'fr' }) y.locale.should.equal('fr') }) }) @@ -43,7 +43,6 @@ describe('y18n', function () { locale: 'pirate', directory: path.join(__dirname, 'locales') }).__ - __`Hi, ${'Ben'} ${''}!`.should.equal('Yarr! Shiver me timbers, why \'tis Ben !') }) it('uses replacements from the default locale if none is configured', function () { diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9318745 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "./node_modules/gts/tsconfig-google.json", + "compilerOptions": { + "outDir": "build", + "rootDir": ".", + "sourceMap": false, + "target": "es2017", + "moduleResolution": "node", + "module": "es2015" + }, + "include": [ + "lib/**/*.ts" + ], + "exclude": [ + "lib/platform-shims/deno.ts" + ] +} \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..b2175ed --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": true + }, + "exclude": [ + "lib/platform-shims/deno.ts" + ] +}