@harpy-js/core 0.4.7
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.
- package/README.md +326 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +53 -0
- package/dist/client/Link.d.ts +5 -0
- package/dist/client/Link.js +62 -0
- package/dist/client/__tests__/getActiveItemId.test.d.ts +1 -0
- package/dist/client/__tests__/getActiveItemId.test.js +38 -0
- package/dist/client/getActiveItemId.d.ts +7 -0
- package/dist/client/getActiveItemId.js +55 -0
- package/dist/client/use-i18n.d.ts +7 -0
- package/dist/client/use-i18n.js +64 -0
- package/dist/core/__tests__/component-analyzer.test.d.ts +1 -0
- package/dist/core/__tests__/component-analyzer.test.js +151 -0
- package/dist/core/__tests__/hydration-manifest.test.d.ts +1 -0
- package/dist/core/__tests__/hydration-manifest.test.js +211 -0
- package/dist/core/__tests__/jsx.engine.test.d.ts +1 -0
- package/dist/core/__tests__/jsx.engine.test.js +118 -0
- package/dist/core/app-setup.d.ts +7 -0
- package/dist/core/app-setup.js +79 -0
- package/dist/core/auto-register.module.d.ts +9 -0
- package/dist/core/auto-register.module.js +18 -0
- package/dist/core/auto-wrap-middleware.d.ts +4 -0
- package/dist/core/auto-wrap-middleware.js +130 -0
- package/dist/core/client-component-wrapper.d.ts +5 -0
- package/dist/core/client-component-wrapper.js +37 -0
- package/dist/core/client-hydration.d.ts +2 -0
- package/dist/core/client-hydration.js +93 -0
- package/dist/core/client-wrapper-browser.d.ts +2 -0
- package/dist/core/client-wrapper-browser.js +22 -0
- package/dist/core/component-analyzer.d.ts +4 -0
- package/dist/core/component-analyzer.js +98 -0
- package/dist/core/component-auto-wrapper.d.ts +2 -0
- package/dist/core/component-auto-wrapper.js +63 -0
- package/dist/core/component-client-wrapper.d.ts +4 -0
- package/dist/core/component-client-wrapper.js +80 -0
- package/dist/core/hydration-generator.d.ts +2 -0
- package/dist/core/hydration-generator.js +98 -0
- package/dist/core/hydration-manifest.d.ts +7 -0
- package/dist/core/hydration-manifest.js +83 -0
- package/dist/core/hydration.d.ts +16 -0
- package/dist/core/hydration.js +72 -0
- package/dist/core/jsx.engine.d.ts +9 -0
- package/dist/core/jsx.engine.js +161 -0
- package/dist/core/live-reload-client.js +32 -0
- package/dist/core/live-reload.controller.d.ts +10 -0
- package/dist/core/live-reload.controller.js +38 -0
- package/dist/core/navigation.service.d.ts +18 -0
- package/dist/core/navigation.service.js +206 -0
- package/dist/core/router.module.d.ts +2 -0
- package/dist/core/router.module.js +21 -0
- package/dist/core/static-assets.controller.d.ts +4 -0
- package/dist/core/static-assets.controller.js +51 -0
- package/dist/core/types/nav.types.d.ts +22 -0
- package/dist/core/types/nav.types.js +2 -0
- package/dist/core/views/layout.d.ts +8 -0
- package/dist/core/views/layout.js +35 -0
- package/dist/decorators/jsx.decorator.d.ts +26 -0
- package/dist/decorators/jsx.decorator.js +10 -0
- package/dist/decorators/layout.decorator.d.ts +4 -0
- package/dist/decorators/layout.decorator.js +29 -0
- package/dist/i18n/__tests__/i18n.helper.test.d.ts +1 -0
- package/dist/i18n/__tests__/i18n.helper.test.js +105 -0
- package/dist/i18n/__tests__/i18n.interceptor.test.d.ts +1 -0
- package/dist/i18n/__tests__/i18n.interceptor.test.js +195 -0
- package/dist/i18n/__tests__/i18n.module.test.d.ts +1 -0
- package/dist/i18n/__tests__/i18n.module.test.js +83 -0
- package/dist/i18n/__tests__/i18n.service.test.d.ts +1 -0
- package/dist/i18n/__tests__/i18n.service.test.js +109 -0
- package/dist/i18n/__tests__/t.test.d.ts +1 -0
- package/dist/i18n/__tests__/t.test.js +66 -0
- package/dist/i18n/i18n-module.options.d.ts +10 -0
- package/dist/i18n/i18n-module.options.js +4 -0
- package/dist/i18n/i18n-switcher.controller.d.ts +12 -0
- package/dist/i18n/i18n-switcher.controller.js +80 -0
- package/dist/i18n/i18n-types.d.ts +8 -0
- package/dist/i18n/i18n-types.js +2 -0
- package/dist/i18n/i18n.helper.d.ts +14 -0
- package/dist/i18n/i18n.helper.js +70 -0
- package/dist/i18n/i18n.interceptor.d.ts +9 -0
- package/dist/i18n/i18n.interceptor.js +99 -0
- package/dist/i18n/i18n.module.d.ts +5 -0
- package/dist/i18n/i18n.module.js +51 -0
- package/dist/i18n/i18n.service.d.ts +12 -0
- package/dist/i18n/i18n.service.js +61 -0
- package/dist/i18n/index.d.ts +10 -0
- package/dist/i18n/index.js +20 -0
- package/dist/i18n/locale.decorator.d.ts +1 -0
- package/dist/i18n/locale.decorator.js +8 -0
- package/dist/i18n/t.d.ts +3 -0
- package/dist/i18n/t.js +16 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +40 -0
- package/package.json +79 -0
- package/scripts/analyze-styles.ts +124 -0
- package/scripts/auto-wrap-exports.ts +239 -0
- package/scripts/build-css.ts +38 -0
- package/scripts/build-hydration.ts +313 -0
- package/scripts/build-page-styles.ts +43 -0
- package/scripts/copy-assets.ts +34 -0
- package/scripts/dev.sh +3 -0
- package/scripts/dev.ts +257 -0
- package/src/cli.ts +71 -0
- package/src/client/Link.tsx +62 -0
- package/src/client/__tests__/getActiveItemId.test.ts +49 -0
- package/src/client/getActiveItemId.ts +54 -0
- package/src/client/use-i18n.ts +111 -0
- package/src/core/__tests__/component-analyzer.test.ts +141 -0
- package/src/core/__tests__/hydration-manifest.test.ts +223 -0
- package/src/core/__tests__/jsx.engine.test.ts +137 -0
- package/src/core/app-setup.ts +114 -0
- package/src/core/auto-register.module.ts +30 -0
- package/src/core/auto-wrap-middleware.ts +165 -0
- package/src/core/client-component-wrapper.ts +72 -0
- package/src/core/client-hydration.tsx +99 -0
- package/src/core/client-wrapper-browser.ts +40 -0
- package/src/core/component-analyzer.ts +89 -0
- package/src/core/component-auto-wrapper.ts +68 -0
- package/src/core/component-client-wrapper.ts +112 -0
- package/src/core/hydration-generator.ts +94 -0
- package/src/core/hydration-manifest.ts +79 -0
- package/src/core/hydration.ts +70 -0
- package/src/core/jsx.engine.ts +205 -0
- package/src/core/live-reload-client.js +32 -0
- package/src/core/live-reload.controller.ts +55 -0
- package/src/core/navigation.service.ts +257 -0
- package/src/core/router.module.ts +9 -0
- package/src/core/static-assets.controller.ts +19 -0
- package/src/core/types/nav.types.ts +53 -0
- package/src/core/views/layout.tsx +61 -0
- package/src/decorators/jsx.decorator.ts +49 -0
- package/src/decorators/layout.decorator.ts +66 -0
- package/src/i18n/__tests__/i18n.helper.test.ts +126 -0
- package/src/i18n/__tests__/i18n.interceptor.test.ts +229 -0
- package/src/i18n/__tests__/i18n.module.test.ts +98 -0
- package/src/i18n/__tests__/i18n.service.test.ts +129 -0
- package/src/i18n/__tests__/t.test.ts +88 -0
- package/src/i18n/i18n-module.options.ts +53 -0
- package/src/i18n/i18n-switcher.controller.ts +99 -0
- package/src/i18n/i18n-types.ts +56 -0
- package/src/i18n/i18n.helper.ts +75 -0
- package/src/i18n/i18n.interceptor.ts +114 -0
- package/src/i18n/i18n.module.ts +45 -0
- package/src/i18n/i18n.service.ts +95 -0
- package/src/i18n/index.ts +37 -0
- package/src/i18n/locale.decorator.ts +10 -0
- package/src/i18n/t.ts +62 -0
- package/src/index.ts +31 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Test, TestingModule } from "@nestjs/testing";
|
|
2
|
+
import { I18nService } from "../i18n.service";
|
|
3
|
+
import { I18N_MODULE_OPTIONS } from "../i18n-module.options";
|
|
4
|
+
import type { I18nModuleOptions } from "../i18n-module.options";
|
|
5
|
+
import { REQUEST } from "@nestjs/core";
|
|
6
|
+
|
|
7
|
+
describe("I18nService", () => {
|
|
8
|
+
let service: I18nService;
|
|
9
|
+
const mockOptions: I18nModuleOptions = {
|
|
10
|
+
defaultLocale: "en",
|
|
11
|
+
locales: ["en", "fr"],
|
|
12
|
+
urlPattern: "query",
|
|
13
|
+
translationsPath: "./dictionaries",
|
|
14
|
+
cookieName: "locale",
|
|
15
|
+
queryParam: "lang",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const mockRequest = {
|
|
19
|
+
locale: "en",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
24
|
+
providers: [
|
|
25
|
+
I18nService,
|
|
26
|
+
{
|
|
27
|
+
provide: I18N_MODULE_OPTIONS,
|
|
28
|
+
useValue: mockOptions,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
provide: REQUEST,
|
|
32
|
+
useValue: mockRequest,
|
|
33
|
+
},
|
|
34
|
+
],
|
|
35
|
+
}).compile();
|
|
36
|
+
|
|
37
|
+
service = await module.resolve<I18nService>(I18nService);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("getLocale", () => {
|
|
41
|
+
it("should return locale from request", () => {
|
|
42
|
+
expect(service.getLocale()).toBe("en");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should return default locale when request locale is not set", async () => {
|
|
46
|
+
const requestWithoutLocale = {};
|
|
47
|
+
const module = await Test.createTestingModule({
|
|
48
|
+
providers: [
|
|
49
|
+
I18nService,
|
|
50
|
+
{
|
|
51
|
+
provide: I18N_MODULE_OPTIONS,
|
|
52
|
+
useValue: mockOptions,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
provide: REQUEST,
|
|
56
|
+
useValue: requestWithoutLocale,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
}).compile();
|
|
60
|
+
|
|
61
|
+
const serviceInstance = await module.resolve<I18nService>(I18nService);
|
|
62
|
+
expect(serviceInstance.getLocale()).toBe("en");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("getDict", () => {
|
|
67
|
+
it("should return empty object when no dictionary loader registered", async () => {
|
|
68
|
+
const dict = await service.getDict();
|
|
69
|
+
expect(dict).toEqual({});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should call registered dictionary loader", async () => {
|
|
73
|
+
const mockDict = { hello: "Hello", goodbye: "Goodbye" };
|
|
74
|
+
const mockLoader = jest.fn().mockResolvedValue(mockDict);
|
|
75
|
+
|
|
76
|
+
service.registerDictionaryLoader(mockLoader);
|
|
77
|
+
|
|
78
|
+
const dict = await service.getDict();
|
|
79
|
+
|
|
80
|
+
expect(mockLoader).toHaveBeenCalledWith("en");
|
|
81
|
+
expect(dict).toEqual(mockDict);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should cache dictionary after first load", async () => {
|
|
85
|
+
const mockDict = { hello: "Hello" };
|
|
86
|
+
const mockLoader = jest.fn().mockResolvedValue(mockDict);
|
|
87
|
+
|
|
88
|
+
service.registerDictionaryLoader(mockLoader);
|
|
89
|
+
|
|
90
|
+
await service.getDict();
|
|
91
|
+
await service.getDict();
|
|
92
|
+
|
|
93
|
+
expect(mockLoader).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("translate", () => {
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
const mockDict = {
|
|
100
|
+
welcome: "Welcome",
|
|
101
|
+
greeting: "Hello {{name}}!",
|
|
102
|
+
nested: {
|
|
103
|
+
key: "Nested value",
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
service.registerDictionaryLoader(jest.fn().mockResolvedValue(mockDict));
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should translate simple key", async () => {
|
|
110
|
+
const result = await service.translate("welcome");
|
|
111
|
+
expect(result).toBe("Welcome");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should translate with variables", async () => {
|
|
115
|
+
const result = await service.translate("greeting", { name: "John" });
|
|
116
|
+
expect(result).toBe("Hello John!");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should translate nested key", async () => {
|
|
120
|
+
const result = await service.translate("nested.key");
|
|
121
|
+
expect(result).toBe("Nested value");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should return empty string for non-existent key", async () => {
|
|
125
|
+
const result = await service.translate("nonexistent");
|
|
126
|
+
expect(result).toBe("");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { t, tUnsafe } from "../t";
|
|
2
|
+
|
|
3
|
+
describe("Translation Function (t)", () => {
|
|
4
|
+
const mockDict = {
|
|
5
|
+
simple: "Hello",
|
|
6
|
+
nested: {
|
|
7
|
+
key: "Nested value",
|
|
8
|
+
deep: {
|
|
9
|
+
value: "Deep nested value",
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
withVar: "Hello {{name}}!",
|
|
13
|
+
withMultipleVars: "Hello {{name}}, you have {{count}} messages",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("Simple translations", () => {
|
|
17
|
+
it("should translate simple key", () => {
|
|
18
|
+
expect(t(mockDict, "simple")).toBe("Hello");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should translate nested key", () => {
|
|
22
|
+
expect(t(mockDict, "nested.key")).toBe("Nested value");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should translate deeply nested key", () => {
|
|
26
|
+
expect(t(mockDict, "nested.deep.value")).toBe("Deep nested value");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("Variable interpolation", () => {
|
|
31
|
+
it("should interpolate single variable", () => {
|
|
32
|
+
expect(t(mockDict as any, "withVar", { name: "John" })).toBe(
|
|
33
|
+
"Hello John!",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should interpolate multiple variables", () => {
|
|
38
|
+
expect(
|
|
39
|
+
t(mockDict as any, "withMultipleVars", { name: "John", count: 5 }),
|
|
40
|
+
).toBe("Hello John, you have 5 messages");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should handle missing variables gracefully", () => {
|
|
44
|
+
expect(t(mockDict as any, "withVar", {} as any)).toBe("Hello !");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should handle numeric variables", () => {
|
|
48
|
+
expect(
|
|
49
|
+
t(mockDict as any, "withMultipleVars", { name: "John", count: 0 }),
|
|
50
|
+
).toBe("Hello John, you have 0 messages");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("Edge cases", () => {
|
|
55
|
+
it("should return empty string for non-existent key", () => {
|
|
56
|
+
expect(t(mockDict, "nonexistent" as any)).toBe("");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should return empty string for non-string value", () => {
|
|
60
|
+
expect(t(mockDict, "nested" as any)).toBe("");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("should handle empty dictionary", () => {
|
|
64
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
65
|
+
expect(t({} as any, "any")).toBe("");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("tUnsafe function", () => {
|
|
70
|
+
it("should work with dynamic keys", () => {
|
|
71
|
+
expect(tUnsafe(mockDict, "simple")).toBe("Hello");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should work with nested keys", () => {
|
|
75
|
+
expect(tUnsafe(mockDict, "nested.key")).toBe("Nested value");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should interpolate variables", () => {
|
|
79
|
+
expect(tUnsafe(mockDict, "withVar", { name: "Jane" })).toBe(
|
|
80
|
+
"Hello Jane!",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should handle non-existent keys", () => {
|
|
85
|
+
expect(tUnsafe(mockDict, "invalid.key")).toBe("");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL pattern for language detection
|
|
3
|
+
* - 'query': /path?lang=en
|
|
4
|
+
* - 'header': Uses headers only (x-lang, accept-language)
|
|
5
|
+
*/
|
|
6
|
+
export type I18nUrlPattern = "query" | "header";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configuration options for I18nModule
|
|
10
|
+
*/
|
|
11
|
+
export interface I18nModuleOptions {
|
|
12
|
+
/**
|
|
13
|
+
* Default locale to use when no locale is detected
|
|
14
|
+
* @default 'en'
|
|
15
|
+
*/
|
|
16
|
+
defaultLocale: string;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* List of supported locales
|
|
20
|
+
* @default ['en']
|
|
21
|
+
*/
|
|
22
|
+
locales: string[];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* URL pattern for language detection
|
|
26
|
+
* @default 'query'
|
|
27
|
+
*/
|
|
28
|
+
urlPattern?: I18nUrlPattern;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Path to the directory containing translation files
|
|
32
|
+
* Files should be named {locale}.json
|
|
33
|
+
* @default './dictionaries'
|
|
34
|
+
*/
|
|
35
|
+
translationsPath?: string;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cookie name for storing locale preference
|
|
39
|
+
* @default 'locale'
|
|
40
|
+
*/
|
|
41
|
+
cookieName?: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Query parameter name for locale (when urlPattern is 'query')
|
|
45
|
+
* @default 'lang'
|
|
46
|
+
*/
|
|
47
|
+
queryParam?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Token for injecting I18n module options
|
|
52
|
+
*/
|
|
53
|
+
export const I18N_MODULE_OPTIONS = "I18N_MODULE_OPTIONS";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Controller,
|
|
3
|
+
Post,
|
|
4
|
+
Body,
|
|
5
|
+
Req,
|
|
6
|
+
Res,
|
|
7
|
+
Get,
|
|
8
|
+
HttpStatus,
|
|
9
|
+
} from "@nestjs/common";
|
|
10
|
+
import { I18nHelper } from "./i18n.helper";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* API Controller for handling locale switching
|
|
14
|
+
*
|
|
15
|
+
* Provides a proper API endpoint for changing locales
|
|
16
|
+
*/
|
|
17
|
+
@Controller("api/i18n")
|
|
18
|
+
export class I18nSwitcherController {
|
|
19
|
+
constructor(private readonly i18nHelper: I18nHelper) {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* API endpoint to switch locale
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```typescript
|
|
26
|
+
* fetch('/api/i18n/switch-locale', {
|
|
27
|
+
* method: 'POST',
|
|
28
|
+
* headers: { 'Content-Type': 'application/json' },
|
|
29
|
+
* body: JSON.stringify({ locale: 'fr' })
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
@Post("switch-locale")
|
|
34
|
+
switchLocale(
|
|
35
|
+
@Body("locale") locale: string,
|
|
36
|
+
@Req() req: any,
|
|
37
|
+
@Res() res: any,
|
|
38
|
+
) {
|
|
39
|
+
// Validate locale
|
|
40
|
+
if (!locale) {
|
|
41
|
+
return res
|
|
42
|
+
.status(HttpStatus.BAD_REQUEST)
|
|
43
|
+
.send({ error: "Locale is required" });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Validate and normalize locale
|
|
47
|
+
const validatedLocale = this.i18nHelper.validateLocale(locale);
|
|
48
|
+
|
|
49
|
+
// Set cookie
|
|
50
|
+
this.i18nHelper.setLocaleCookie(res, validatedLocale);
|
|
51
|
+
|
|
52
|
+
// Get the referer (where the user came from) or default to homepage
|
|
53
|
+
const referer = req.headers.referer || req.headers.origin || "/";
|
|
54
|
+
let currentPath = "/";
|
|
55
|
+
let query: Record<string, string> = {};
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const refererUrl = new URL(referer);
|
|
59
|
+
// Security check: validate it's from the same origin to prevent open redirect
|
|
60
|
+
const requestOrigin = `${req.protocol}://${req.hostname}${req.hostname === "localhost" && req.port ? ":" + req.port : ""}`;
|
|
61
|
+
|
|
62
|
+
if (refererUrl.origin !== requestOrigin) {
|
|
63
|
+
// External origin - use root path for security
|
|
64
|
+
currentPath = "/";
|
|
65
|
+
} else {
|
|
66
|
+
currentPath = refererUrl.pathname;
|
|
67
|
+
// Parse query params from referer
|
|
68
|
+
refererUrl.searchParams.forEach((value, key) => {
|
|
69
|
+
query[key] = value;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// If referer is not a valid URL, use root path
|
|
74
|
+
currentPath = "/";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Build redirect URL based on the page the user was on
|
|
78
|
+
const redirectUrl = this.i18nHelper.buildLocaleUrl(
|
|
79
|
+
validatedLocale,
|
|
80
|
+
currentPath,
|
|
81
|
+
query,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Return the URL for client-side navigation
|
|
85
|
+
return res.send({
|
|
86
|
+
success: true,
|
|
87
|
+
locale: validatedLocale,
|
|
88
|
+
redirectUrl,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get i18n configuration for client-side usage
|
|
94
|
+
*/
|
|
95
|
+
@Get("config")
|
|
96
|
+
getConfig() {
|
|
97
|
+
return this.i18nHelper.getClientConfig();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type utilities for type-safe translations
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract nested keys from an object type with dot notation
|
|
7
|
+
* @example
|
|
8
|
+
* type Dict = { user: { name: string; age: number }; welcome: string };
|
|
9
|
+
* type Keys = NestedKeyOf<Dict>; // 'user.name' | 'user.age' | 'welcome'
|
|
10
|
+
*/
|
|
11
|
+
export type NestedKeyOf<ObjectType extends object> = {
|
|
12
|
+
[Key in keyof ObjectType & string]: ObjectType[Key] extends object
|
|
13
|
+
? ObjectType[Key] extends Array<any>
|
|
14
|
+
? Key
|
|
15
|
+
: `${Key}` | `${Key}.${NestedKeyOf<ObjectType[Key]>}`
|
|
16
|
+
: Key;
|
|
17
|
+
}[keyof ObjectType & string];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extract the value type at a given key path
|
|
21
|
+
* @example
|
|
22
|
+
* type Dict = { user: { name: string }; count: number };
|
|
23
|
+
* type Value = DeepValue<Dict, 'user.name'>; // string
|
|
24
|
+
*/
|
|
25
|
+
export type DeepValue<
|
|
26
|
+
T,
|
|
27
|
+
K extends string,
|
|
28
|
+
> = K extends `${infer First}.${infer Rest}`
|
|
29
|
+
? First extends keyof T
|
|
30
|
+
? Rest extends string
|
|
31
|
+
? DeepValue<T[First], Rest>
|
|
32
|
+
: never
|
|
33
|
+
: never
|
|
34
|
+
: K extends keyof T
|
|
35
|
+
? T[K]
|
|
36
|
+
: never;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract variable names from a translation string
|
|
40
|
+
* @example
|
|
41
|
+
* type Vars = ExtractVariables<"Hello {{name}}, you have {{count}} messages">;
|
|
42
|
+
* // { name: string | number; count: string | number }
|
|
43
|
+
*/
|
|
44
|
+
export type ExtractVariables<T extends string> =
|
|
45
|
+
T extends `${string}{{${infer Var}}}${infer Rest}`
|
|
46
|
+
? { [K in Var | keyof ExtractVariables<Rest>]: string | number }
|
|
47
|
+
: Record<string, never>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if a translation string requires variables
|
|
51
|
+
*/
|
|
52
|
+
export type RequiresVariables<T> = T extends string
|
|
53
|
+
? T extends `${string}{{${string}}}${string}`
|
|
54
|
+
? true
|
|
55
|
+
: false
|
|
56
|
+
: false;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Inject, Injectable } from "@nestjs/common";
|
|
2
|
+
import type { I18nModuleOptions } from "./i18n-module.options";
|
|
3
|
+
import { I18N_MODULE_OPTIONS } from "./i18n-module.options";
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class I18nHelper {
|
|
7
|
+
constructor(
|
|
8
|
+
@Inject(I18N_MODULE_OPTIONS) private readonly options: I18nModuleOptions,
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the URL for switching locale based on the configured URL pattern
|
|
13
|
+
*/
|
|
14
|
+
buildLocaleUrl(
|
|
15
|
+
locale: string,
|
|
16
|
+
currentPath: string,
|
|
17
|
+
query?: Record<string, string>,
|
|
18
|
+
): string {
|
|
19
|
+
const { urlPattern, queryParam = "lang" } = this.options;
|
|
20
|
+
|
|
21
|
+
switch (urlPattern) {
|
|
22
|
+
case "query": {
|
|
23
|
+
const params = new URLSearchParams(query || {});
|
|
24
|
+
params.set(queryParam, locale);
|
|
25
|
+
return `${currentPath}?${params.toString()}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
case "header":
|
|
29
|
+
default: {
|
|
30
|
+
// For header-based, just redirect to same URL
|
|
31
|
+
// Cookie will be set and header will be checked on next request
|
|
32
|
+
if (query && Object.keys(query).length > 0) {
|
|
33
|
+
const params = new URLSearchParams(query);
|
|
34
|
+
return `${currentPath}?${params.toString()}`;
|
|
35
|
+
}
|
|
36
|
+
return currentPath;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Set the locale cookie in the response (Fastify)
|
|
43
|
+
*/
|
|
44
|
+
setLocaleCookie(res: any, locale: string): void {
|
|
45
|
+
const { cookieName = "locale" } = this.options;
|
|
46
|
+
res.setCookie(cookieName, locale, {
|
|
47
|
+
path: "/",
|
|
48
|
+
maxAge: 365 * 24 * 60 * 60, // 1 year in seconds for Fastify
|
|
49
|
+
httpOnly: false, // Allow client-side reading if needed
|
|
50
|
+
sameSite: "lax",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate locale
|
|
56
|
+
*/
|
|
57
|
+
validateLocale(locale: string): string {
|
|
58
|
+
if (!this.options.locales?.includes(locale)) {
|
|
59
|
+
return this.options.defaultLocale || "en";
|
|
60
|
+
}
|
|
61
|
+
return locale;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get configuration for client-side usage
|
|
66
|
+
*/
|
|
67
|
+
getClientConfig() {
|
|
68
|
+
return {
|
|
69
|
+
locales: this.options.locales || ["en"],
|
|
70
|
+
defaultLocale: this.options.defaultLocale || "en",
|
|
71
|
+
urlPattern: this.options.urlPattern || "query",
|
|
72
|
+
queryParam: this.options.queryParam || "lang",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
NestInterceptor,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
CallHandler,
|
|
6
|
+
Inject,
|
|
7
|
+
} from "@nestjs/common";
|
|
8
|
+
import { Observable } from "rxjs";
|
|
9
|
+
import { I18N_MODULE_OPTIONS } from "./i18n-module.options";
|
|
10
|
+
import type { I18nModuleOptions } from "./i18n-module.options";
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class I18nInterceptor implements NestInterceptor {
|
|
14
|
+
constructor(
|
|
15
|
+
@Inject(I18N_MODULE_OPTIONS) private options: I18nModuleOptions,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
19
|
+
const request = context.switchToHttp().getRequest();
|
|
20
|
+
const response = context.switchToHttp().getResponse();
|
|
21
|
+
const locales = this.options.locales;
|
|
22
|
+
const defaultLocale = this.options.defaultLocale;
|
|
23
|
+
|
|
24
|
+
let locale: string | undefined = undefined;
|
|
25
|
+
let shouldSetCookie = false;
|
|
26
|
+
|
|
27
|
+
// Extract locale based on URL pattern
|
|
28
|
+
switch (this.options.urlPattern) {
|
|
29
|
+
case "query":
|
|
30
|
+
// Extract from query parameter
|
|
31
|
+
const queryLang = request.query?.[this.options.queryParam || "lang"];
|
|
32
|
+
if (queryLang && locales.includes(queryLang)) {
|
|
33
|
+
locale = queryLang;
|
|
34
|
+
shouldSetCookie = true; // Set cookie when lang is in URL
|
|
35
|
+
}
|
|
36
|
+
break;
|
|
37
|
+
|
|
38
|
+
case "header":
|
|
39
|
+
// Extract from x-lang header
|
|
40
|
+
const xLang = request.headers["x-lang"];
|
|
41
|
+
if (xLang && locales.includes(xLang)) {
|
|
42
|
+
locale = xLang;
|
|
43
|
+
shouldSetCookie = true; // Set cookie when header is present
|
|
44
|
+
}
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fallback to cookie if not found in URL/header
|
|
49
|
+
const cookieLang = this.parseCookie(
|
|
50
|
+
request.headers.cookie,
|
|
51
|
+
this.options.cookieName || "locale",
|
|
52
|
+
);
|
|
53
|
+
if (!locale) {
|
|
54
|
+
if (cookieLang && locales.includes(cookieLang)) {
|
|
55
|
+
locale = cookieLang;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Fallback to accept-language header
|
|
60
|
+
if (!locale) {
|
|
61
|
+
const acceptLanguage = request.headers["accept-language"];
|
|
62
|
+
if (acceptLanguage) {
|
|
63
|
+
const preferredLocale = acceptLanguage
|
|
64
|
+
.split(",")[0]
|
|
65
|
+
.split("-")[0]
|
|
66
|
+
.toLowerCase();
|
|
67
|
+
|
|
68
|
+
if (locales.includes(preferredLocale)) {
|
|
69
|
+
locale = preferredLocale;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Use default locale if nothing matched
|
|
75
|
+
if (!locale) {
|
|
76
|
+
locale = defaultLocale;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Store locale in request object for later use
|
|
80
|
+
request.locale = locale;
|
|
81
|
+
|
|
82
|
+
// Automatically set cookie if locale was found in URL or header
|
|
83
|
+
if (shouldSetCookie && cookieLang !== locale) {
|
|
84
|
+
const cookieName = this.options.cookieName || "locale";
|
|
85
|
+
const maxAge = 365 * 24 * 60 * 60; // 1 year in seconds
|
|
86
|
+
response.header(
|
|
87
|
+
"Set-Cookie",
|
|
88
|
+
`${cookieName}=${locale}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return next.handle();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private parseCookie(
|
|
96
|
+
cookieHeader: string | undefined,
|
|
97
|
+
name: string,
|
|
98
|
+
): string | undefined {
|
|
99
|
+
if (!cookieHeader) return undefined;
|
|
100
|
+
|
|
101
|
+
const cookies = cookieHeader.split(";").map((c) => c.trim());
|
|
102
|
+
const cookie = cookies.find((c) => c.startsWith(`${name}=`));
|
|
103
|
+
|
|
104
|
+
if (!cookie) return undefined;
|
|
105
|
+
const eqIndex = cookie.indexOf("=");
|
|
106
|
+
if (eqIndex === -1) return undefined;
|
|
107
|
+
const value = cookie.slice(eqIndex + 1);
|
|
108
|
+
try {
|
|
109
|
+
return decodeURIComponent(value);
|
|
110
|
+
} catch {
|
|
111
|
+
return value; // Return as-is if decoding fails
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Module, Global, DynamicModule } from "@nestjs/common";
|
|
2
|
+
import { APP_INTERCEPTOR } from "@nestjs/core";
|
|
3
|
+
import { I18nService } from "./i18n.service";
|
|
4
|
+
import { I18nInterceptor } from "./i18n.interceptor";
|
|
5
|
+
import { I18nHelper } from "./i18n.helper";
|
|
6
|
+
import { I18nSwitcherController } from "./i18n-switcher.controller";
|
|
7
|
+
import { I18nModuleOptions, I18N_MODULE_OPTIONS } from "./i18n-module.options";
|
|
8
|
+
|
|
9
|
+
@Global()
|
|
10
|
+
@Module({})
|
|
11
|
+
export class I18nModule {
|
|
12
|
+
/**
|
|
13
|
+
* Register I18n module with configuration
|
|
14
|
+
* @param options Configuration options for i18n
|
|
15
|
+
*/
|
|
16
|
+
static forRoot(options: I18nModuleOptions): DynamicModule {
|
|
17
|
+
// Set defaults
|
|
18
|
+
const moduleOptions: Required<I18nModuleOptions> = {
|
|
19
|
+
defaultLocale: options.defaultLocale || "en",
|
|
20
|
+
locales: options.locales || ["en"],
|
|
21
|
+
urlPattern: options.urlPattern || "query",
|
|
22
|
+
translationsPath: options.translationsPath || "./dictionaries",
|
|
23
|
+
cookieName: options.cookieName || "locale",
|
|
24
|
+
queryParam: options.queryParam || "lang",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
module: I18nModule,
|
|
29
|
+
controllers: [I18nSwitcherController],
|
|
30
|
+
providers: [
|
|
31
|
+
{
|
|
32
|
+
provide: I18N_MODULE_OPTIONS,
|
|
33
|
+
useValue: moduleOptions,
|
|
34
|
+
},
|
|
35
|
+
I18nService,
|
|
36
|
+
I18nHelper,
|
|
37
|
+
{
|
|
38
|
+
provide: APP_INTERCEPTOR,
|
|
39
|
+
useClass: I18nInterceptor,
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
exports: [I18nService, I18nHelper, I18N_MODULE_OPTIONS],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|