@hazeljs/i18n 0.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/LICENSE +192 -0
  2. package/README.md +292 -0
  3. package/dist/decorators/lang.decorator.d.ts +33 -0
  4. package/dist/decorators/lang.decorator.d.ts.map +1 -0
  5. package/dist/decorators/lang.decorator.js +53 -0
  6. package/dist/decorators/lang.decorator.test.d.ts +2 -0
  7. package/dist/decorators/lang.decorator.test.d.ts.map +1 -0
  8. package/dist/decorators/lang.decorator.test.js +61 -0
  9. package/dist/i18n.interceptor.d.ts +29 -0
  10. package/dist/i18n.interceptor.d.ts.map +1 -0
  11. package/dist/i18n.interceptor.js +52 -0
  12. package/dist/i18n.interceptor.test.d.ts +2 -0
  13. package/dist/i18n.interceptor.test.d.ts.map +1 -0
  14. package/dist/i18n.interceptor.test.js +126 -0
  15. package/dist/i18n.middleware.d.ts +59 -0
  16. package/dist/i18n.middleware.d.ts.map +1 -0
  17. package/dist/i18n.middleware.js +126 -0
  18. package/dist/i18n.middleware.test.d.ts +2 -0
  19. package/dist/i18n.middleware.test.d.ts.map +1 -0
  20. package/dist/i18n.middleware.test.js +239 -0
  21. package/dist/i18n.module.d.ts +73 -0
  22. package/dist/i18n.module.d.ts.map +1 -0
  23. package/dist/i18n.module.js +154 -0
  24. package/dist/i18n.module.test.d.ts +2 -0
  25. package/dist/i18n.module.test.d.ts.map +1 -0
  26. package/dist/i18n.module.test.js +162 -0
  27. package/dist/i18n.service.d.ts +112 -0
  28. package/dist/i18n.service.d.ts.map +1 -0
  29. package/dist/i18n.service.js +228 -0
  30. package/dist/i18n.service.test.d.ts +2 -0
  31. package/dist/i18n.service.test.d.ts.map +1 -0
  32. package/dist/i18n.service.test.js +297 -0
  33. package/dist/index.d.ts +27 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +39 -0
  36. package/dist/translation.loader.d.ts +20 -0
  37. package/dist/translation.loader.d.ts.map +1 -0
  38. package/dist/translation.loader.js +51 -0
  39. package/dist/translation.loader.test.d.ts +2 -0
  40. package/dist/translation.loader.test.d.ts.map +1 -0
  41. package/dist/translation.loader.test.js +89 -0
  42. package/dist/types.d.ts +86 -0
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/types.js +2 -0
  45. package/package.json +51 -0
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TranslationLoader = void 0;
4
+ const fs_1 = require("fs");
5
+ const path_1 = require("path");
6
+ /**
7
+ * Loads JSON translation files from a directory and returns a locale store.
8
+ *
9
+ * Each file must be named <locale>.json (e.g. en.json, fr.json, zh-TW.json).
10
+ * Nested objects in the JSON are kept as-is; key lookup via dot-notation is
11
+ * resolved at translation time inside I18nService.
12
+ *
13
+ * Synchronous I/O is intentional: HazelJS's DI container resolves providers
14
+ * synchronously, so async factories would result in a Promise being stored
15
+ * as the service instance rather than the resolved value.
16
+ */
17
+ class TranslationLoader {
18
+ /**
19
+ * Read all *.json files from the given directory synchronously.
20
+ * Returns a Map keyed by locale code (the filename without extension).
21
+ */
22
+ static load(translationsPath) {
23
+ const store = new Map();
24
+ let entries;
25
+ try {
26
+ entries = (0, fs_1.readdirSync)(translationsPath);
27
+ }
28
+ catch {
29
+ // Directory does not exist — return an empty store so the service starts
30
+ // gracefully without translations (it will return keys as-is).
31
+ return store;
32
+ }
33
+ const jsonFiles = entries.filter((file) => (0, path_1.extname)(file) === '.json');
34
+ for (const file of jsonFiles) {
35
+ const locale = (0, path_1.basename)(file, '.json');
36
+ const filePath = (0, path_1.join)(translationsPath, file);
37
+ try {
38
+ const raw = (0, fs_1.readFileSync)(filePath, 'utf-8');
39
+ const translations = JSON.parse(raw);
40
+ store.set(locale, translations);
41
+ }
42
+ catch (err) {
43
+ // Skip malformed files rather than crashing the application.
44
+ const message = err instanceof Error ? err.message : String(err);
45
+ process.stderr.write(`[@hazeljs/i18n] Failed to load translation file "${filePath}": ${message}\n`);
46
+ }
47
+ }
48
+ return store;
49
+ }
50
+ }
51
+ exports.TranslationLoader = TranslationLoader;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=translation.loader.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translation.loader.test.d.ts","sourceRoot":"","sources":["../src/translation.loader.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const translation_loader_1 = require("./translation.loader");
4
+ jest.mock('fs', () => ({
5
+ readdirSync: jest.fn(),
6
+ readFileSync: jest.fn(),
7
+ }));
8
+ const fs_1 = require("fs");
9
+ const mockReaddirSync = fs_1.readdirSync;
10
+ const mockReadFileSync = fs_1.readFileSync;
11
+ describe('TranslationLoader', () => {
12
+ beforeEach(() => {
13
+ jest.clearAllMocks();
14
+ });
15
+ describe('load()', () => {
16
+ it('returns an empty store when directory does not exist', () => {
17
+ mockReaddirSync.mockImplementation(() => {
18
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
19
+ });
20
+ const store = translation_loader_1.TranslationLoader.load('/nonexistent/path');
21
+ expect(store.size).toBe(0);
22
+ });
23
+ it('returns an empty store when directory is empty', () => {
24
+ mockReaddirSync.mockReturnValue([]);
25
+ const store = translation_loader_1.TranslationLoader.load('/empty/dir');
26
+ expect(store.size).toBe(0);
27
+ });
28
+ it('ignores non-JSON files', () => {
29
+ mockReaddirSync.mockReturnValue(['README.md', 'notes.txt']);
30
+ const store = translation_loader_1.TranslationLoader.load('/some/dir');
31
+ expect(store.size).toBe(0);
32
+ expect(mockReadFileSync).not.toHaveBeenCalled();
33
+ });
34
+ it('loads a single JSON file and stores translations by locale', () => {
35
+ mockReaddirSync.mockReturnValue(['en.json']);
36
+ mockReadFileSync.mockReturnValue('{"hello":"Hello","bye":"Goodbye"}');
37
+ const store = translation_loader_1.TranslationLoader.load('/translations');
38
+ expect(store.has('en')).toBe(true);
39
+ expect(store.get('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' });
40
+ });
41
+ it('loads multiple JSON files into separate locale entries', () => {
42
+ mockReaddirSync.mockReturnValue(['en.json', 'fr.json']);
43
+ mockReadFileSync
44
+ .mockReturnValueOnce('{"hello":"Hello"}')
45
+ .mockReturnValueOnce('{"hello":"Bonjour"}');
46
+ const store = translation_loader_1.TranslationLoader.load('/translations');
47
+ expect(store.size).toBe(2);
48
+ expect(store.get('en')).toEqual({ hello: 'Hello' });
49
+ expect(store.get('fr')).toEqual({ hello: 'Bonjour' });
50
+ });
51
+ it('handles locale codes with hyphens (zh-TW.json)', () => {
52
+ mockReaddirSync.mockReturnValue(['zh-TW.json']);
53
+ mockReadFileSync.mockReturnValue('{"greeting":"你好"}');
54
+ const store = translation_loader_1.TranslationLoader.load('/translations');
55
+ expect(store.has('zh-TW')).toBe(true);
56
+ });
57
+ it('skips malformed JSON files and writes to stderr', () => {
58
+ const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
59
+ mockReaddirSync.mockReturnValue(['en.json', 'bad.json']);
60
+ mockReadFileSync
61
+ .mockReturnValueOnce('{"ok":true}')
62
+ .mockReturnValueOnce('{ invalid json');
63
+ const store = translation_loader_1.TranslationLoader.load('/translations');
64
+ expect(store.has('en')).toBe(true);
65
+ expect(store.has('bad')).toBe(false);
66
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('[@hazeljs/i18n]'));
67
+ stderrSpy.mockRestore();
68
+ });
69
+ it('writes non-Error exception to stderr as string', () => {
70
+ const stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
71
+ mockReaddirSync.mockReturnValue(['broken.json']);
72
+ mockReadFileSync.mockImplementation(() => {
73
+ throw 'string error';
74
+ });
75
+ const store = translation_loader_1.TranslationLoader.load('/translations');
76
+ expect(store.has('broken')).toBe(false);
77
+ expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('string error'));
78
+ stderrSpy.mockRestore();
79
+ });
80
+ it('loads nested translation objects', () => {
81
+ mockReaddirSync.mockReturnValue(['en.json']);
82
+ mockReadFileSync.mockReturnValue(JSON.stringify({ errors: { notFound: 'Not found', invalid: 'Invalid' } }));
83
+ const store = translation_loader_1.TranslationLoader.load('/translations');
84
+ expect(store.get('en')).toEqual({
85
+ errors: { notFound: 'Not found', invalid: 'Invalid' },
86
+ });
87
+ });
88
+ });
89
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Supported locale detection strategies, in order of priority.
3
+ */
4
+ export type LocaleDetectionStrategy = 'query' | 'cookie' | 'header';
5
+ /**
6
+ * A flat or nested map of translation keys to values.
7
+ * Nested objects represent namespaced keys (e.g. "errors.notFound").
8
+ * Leaf values are translation strings, which may contain {placeholder} tokens
9
+ * and optional plural forms via a "one"/"other" sub-object.
10
+ */
11
+ export type TranslationValue = string | {
12
+ one: string;
13
+ other: string;
14
+ [form: string]: string;
15
+ } | {
16
+ [key: string]: TranslationValue;
17
+ };
18
+ export type TranslationMap = Record<string, TranslationValue>;
19
+ /**
20
+ * In-memory store: locale → flat translation map.
21
+ */
22
+ export type LocaleStore = Map<string, TranslationMap>;
23
+ /**
24
+ * Options for interpolating variables and selecting plural forms.
25
+ */
26
+ export interface TranslateOptions {
27
+ /**
28
+ * Used for plural rule selection.
29
+ */
30
+ count?: number;
31
+ /**
32
+ * Key-value pairs substituted into {placeholder} tokens.
33
+ */
34
+ vars?: Record<string, string | number>;
35
+ /**
36
+ * Override the locale for this single call.
37
+ */
38
+ locale?: string;
39
+ }
40
+ /**
41
+ * Configuration options for I18nModule.forRoot().
42
+ */
43
+ export interface I18nOptions {
44
+ /**
45
+ * The locale used when no locale can be detected.
46
+ * @default 'en'
47
+ */
48
+ defaultLocale?: string;
49
+ /**
50
+ * The locale to fall back to when a key is missing in the requested locale.
51
+ * @default same as defaultLocale
52
+ */
53
+ fallbackLocale?: string;
54
+ /**
55
+ * Absolute or relative path to the directory containing JSON translation files.
56
+ * Files must be named <locale>.json (e.g. en.json, fr.json).
57
+ * @default './translations'
58
+ */
59
+ translationsPath?: string;
60
+ /**
61
+ * Ordered list of locale-detection strategies to apply per request.
62
+ * @default ['query', 'cookie', 'header']
63
+ */
64
+ detection?: LocaleDetectionStrategy[];
65
+ /**
66
+ * Name of the query-string parameter to check.
67
+ * @default 'lang'
68
+ */
69
+ queryParam?: string;
70
+ /**
71
+ * Name of the cookie to check.
72
+ * @default 'locale'
73
+ */
74
+ cookieName?: string;
75
+ /**
76
+ * Whether to register this module globally so it does not need to be
77
+ * imported in every feature module.
78
+ * @default true
79
+ */
80
+ isGlobal?: boolean;
81
+ }
82
+ /**
83
+ * Fully resolved options with all defaults applied.
84
+ */
85
+ export type ResolvedI18nOptions = Required<I18nOptions>;
86
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,MAAM,uBAAuB,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEpE;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GACxB,MAAM,GACN;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,GACtD;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,CAAA;CAAE,CAAC;AAExC,MAAM,MAAM,cAAc,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;AAE9D;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;AAEtD;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B;;OAEG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf;;OAEG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IAEvC;;OAEG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IAEvB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAE1B;;;OAGG;IACH,SAAS,CAAC,EAAE,uBAAuB,EAAE,CAAC;IAEtC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC,WAAW,CAAC,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@hazeljs/i18n",
3
+ "version": "0.2.0-alpha.1",
4
+ "description": "Internationalization (i18n) module for HazelJS framework",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "jest --coverage",
13
+ "lint": "eslint \"src/**/*.ts\"",
14
+ "lint:fix": "eslint \"src/**/*.ts\" --fix",
15
+ "clean": "rm -rf dist"
16
+ },
17
+ "devDependencies": {
18
+ "@types/node": "^20.17.50",
19
+ "@typescript-eslint/eslint-plugin": "^8.18.2",
20
+ "@typescript-eslint/parser": "^8.18.2",
21
+ "eslint": "^8.56.0",
22
+ "jest": "^29.7.0",
23
+ "ts-jest": "^29.1.2",
24
+ "typescript": "^5.3.3"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/hazel-js/hazeljs.git",
32
+ "directory": "packages/i18n"
33
+ },
34
+ "keywords": [
35
+ "hazeljs",
36
+ "i18n",
37
+ "internationalization",
38
+ "localization",
39
+ "translation"
40
+ ],
41
+ "author": "Muhammad Arslan <muhammad.arslan@hazeljs.com>",
42
+ "license": "Apache-2.0",
43
+ "bugs": {
44
+ "url": "https://github.com/hazeljs/hazel-js/issues"
45
+ },
46
+ "homepage": "https://hazeljs.com",
47
+ "peerDependencies": {
48
+ "@hazeljs/core": ">=0.2.0-beta.0"
49
+ },
50
+ "gitHead": "cbc5ee2c12ced28fd0576faf13c5f078c1e8421e"
51
+ }