diff --git a/.eslintrc b/.eslintrc index 9251461..d3e93aa 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,5 +5,8 @@ "env": { "jest": true, "node": true + }, + "rules": { + "@typescript-eslint/no-non-null-assertion": "off" } } diff --git a/README.md b/README.md index 4c01847..f47785f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build](https://github.com/Widen/i18next-async-backend/actions/workflows/build.yml/badge.svg)](https://github.com/Widen/i18next-async-backend/actions/workflows/build.yml) [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) -i18next backend which loads resources via promises. Useful when loading resources via dynamic imports. +i18next backend which loads resources via promises. Useful when loading resources via dynamic imports. ## Installation @@ -21,7 +21,18 @@ yarn add i18next-async-backend ## Usage -TODO +```js +import i18next from 'i18next' +import AsyncBackend from 'i18next-async-backend' + +i18next.use(AsyncBackend).init({ + backend: { + resources: { + en: {}, + }, + }, +}) +``` ## Releasing diff --git a/package.json b/package.json index a025a65..f6b0545 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "i18next-async-backend", "version": "0.0.0-semantically-released", - "description": "i18next backend which loads resources via promises. Useful when loading resources via dynamic imports.", + "description": "i18next backend which loads resources via promises. Useful when loading resources via dynamic imports.", "author": "Widen", "license": "ISC", "repository": "github:Widen/i18next-async-backend", @@ -9,7 +9,12 @@ "bugs": { "url": "https://github.com/Widen/i18next-async-backend/issues" }, - "keywords": [], + "keywords": [ + "i18next", + "i18next-backend", + "i18next-plugin", + "dynamic-import" + ], "exports": "./lib/index.js", "types": "lib/index.d.ts", "files": [ @@ -26,11 +31,15 @@ "main" ] }, + "peerDependencies": { + "i18next": ">=20" + }, "devDependencies": { "@types/jest": "^26.0.22", "@typescript-eslint/eslint-plugin": "^4.19.0", "@typescript-eslint/parser": "^4.19.0", "eslint": "^7.22.0", + "i18next": "^20.1.0", "jest": "^26.6.3", "prettier": "^2.2.1", "semantic-release": "^17.4.2", diff --git a/src/index.ts b/src/index.ts index 41c96b3..3a10f6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,47 @@ -export function run(): boolean { - return true +import { BackendModule, ReadCallback, Services } from 'i18next' + +type ResourceFetcher = () => Promise> + +export interface AsyncBackendOptions { + resources?: { + [language: string]: ResourceFetcher | Record + } +} + +export default class AsyncBackend + implements BackendModule { + // i18next is dumb as TypeScript requires the class property for `type` + // but the runtime requires the static `type` property. + static type = 'backend' + type = 'backend' as const + + private options: AsyncBackendOptions = null! + + constructor(services: Services, options: AsyncBackendOptions) { + this.init(services, options) + } + + init(_: Services, options: AsyncBackendOptions): void { + this.options = { ...this.options, ...options } + } + + read(lng: string, ns: string, callback: ReadCallback): void { + const resourceFetcher = this.getResourceFetcher(lng, ns) + + if (resourceFetcher) { + resourceFetcher() + .then((resource) => callback(null, resource)) + .catch((err) => callback(err, false)) + } else { + callback(new Error('resource not found'), false) + } + } + + private getResourceFetcher(lng: string, ns: string) { + // Languages can specify a function if they only have a single namespace + // or an object if they have multiple namespaces. + const fetcher = this.options.resources?.[lng] + + return typeof fetcher === 'function' ? fetcher : fetcher?.[ns] + } } diff --git a/test/AsyncBackend.spec.ts b/test/AsyncBackend.spec.ts new file mode 100644 index 0000000..2585282 --- /dev/null +++ b/test/AsyncBackend.spec.ts @@ -0,0 +1,60 @@ +import i18next from 'i18next' +import AsyncBackend, { AsyncBackendOptions } from '../src' + +async function init(ns: string[], resources: AsyncBackendOptions['resources']) { + const i18n = i18next.createInstance().use(AsyncBackend) + + const t = await i18n.init({ + backend: { resources }, + fallbackLng: 'en', + defaultNS: ns[0], + ns, + }) + + return { i18n, t } +} + +it('should accept a function if there is only one namespace', async () => { + const { t } = await init(['translation'], { + en: () => Promise.resolve({ foo: 'bar' }), + }) + + expect(t('foo')).toBe('bar') +}) + +it('should accept an object if there are multiple namespaces', async () => { + const { t } = await init(['ns1', 'ns2'], { + en: { + ns1: () => Promise.resolve({ fruit: 'Apples' }), + ns2: () => Promise.resolve({ fruit: 'Oranges' }), + }, + }) + + expect(t('fruit')).toBe('Apples') + expect(t('ns1:fruit')).toBe('Apples') + expect(t('ns2:fruit')).toBe('Oranges') +}) + +it('should load multiple languages', async () => { + const { t, i18n } = await init(['ns1', 'ns2'], { + en: { + ns1: () => Promise.resolve({ fruit: 'Apples' }), + ns2: () => Promise.resolve({ fruit: 'Oranges' }), + }, + es: { + ns1: () => Promise.resolve({ fruit: 'Manzanas' }), + ns2: () => Promise.resolve({ fruit: 'Naranjas' }), + }, + }) + + // English + expect(t('fruit')).toBe('Apples') + expect(t('ns1:fruit')).toBe('Apples') + expect(t('ns2:fruit')).toBe('Oranges') + + // Spanish + await i18n.changeLanguage('es') + expect(t('fruit')).toBe('Manzanas') + expect(t('ns1:fruit')).toBe('Manzanas') + expect(t('ns2:fruit')).toBe('Naranjas') +}) diff --git a/test/index.spec.ts b/test/index.spec.ts deleted file mode 100644 index 68c2296..0000000 --- a/test/index.spec.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { run } from '../src' - -it('should pass', () => { - expect(run()).toBe(true) -}) diff --git a/yarn.lock b/yarn.lock index 0d80460..fc0bbf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -356,6 +356,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.12.0": + version: 7.13.10 + resolution: "@babel/runtime@npm:7.13.10" + dependencies: + regenerator-runtime: ^0.13.4 + checksum: 22014226b96a8c8e8d4e8bcdb011f317d1b32881aef424a669dc6ceaee14993d3609172967853cbf9c25c724c25145d45885b6c9df56ba241c12820776607f1f + languageName: node + linkType: hard + "@babel/template@npm:^7.12.13, @babel/template@npm:^7.3.3": version: 7.12.13 resolution: "@babel/template@npm:7.12.13" @@ -4142,14 +4151,26 @@ fsevents@^2.1.2: "@typescript-eslint/eslint-plugin": ^4.19.0 "@typescript-eslint/parser": ^4.19.0 eslint: ^7.22.0 + i18next: ^20.1.0 jest: ^26.6.3 prettier: ^2.2.1 semantic-release: ^17.4.2 ts-jest: ^26.5.4 typescript: ^4.2.3 + peerDependencies: + i18next: ">=20" languageName: unknown linkType: soft +"i18next@npm:^20.1.0": + version: 20.1.0 + resolution: "i18next@npm:20.1.0" + dependencies: + "@babel/runtime": ^7.12.0 + checksum: fb95e19026c2d3d9376087ddfb33c65ba98c80d3e1fb62b93edf7f6b178a492cd5a54bf139edd515a774ee65a75b0051774fdfcf7630f577c26f7014cad2bb12 + languageName: node + linkType: hard + "iconv-lite@npm:0.4.24, iconv-lite@npm:~0.4.13": version: 0.4.24 resolution: "iconv-lite@npm:0.4.24" @@ -7667,6 +7688,13 @@ fsevents@^2.1.2: languageName: node linkType: hard +"regenerator-runtime@npm:^0.13.4": + version: 0.13.8 + resolution: "regenerator-runtime@npm:0.13.8" + checksum: 20178f5753f181d59691e5c3b4c59a2769987f75c7ccf325777673b5478acca61a553b10e895585086c222f72f5ee428090acf50320264de4b79f630f7388653 + languageName: node + linkType: hard + "regex-not@npm:^1.0.0, regex-not@npm:^1.0.2": version: 1.0.2 resolution: "regex-not@npm:1.0.2"