@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.
Files changed (147) hide show
  1. package/README.md +326 -0
  2. package/dist/cli.d.ts +12 -0
  3. package/dist/cli.js +53 -0
  4. package/dist/client/Link.d.ts +5 -0
  5. package/dist/client/Link.js +62 -0
  6. package/dist/client/__tests__/getActiveItemId.test.d.ts +1 -0
  7. package/dist/client/__tests__/getActiveItemId.test.js +38 -0
  8. package/dist/client/getActiveItemId.d.ts +7 -0
  9. package/dist/client/getActiveItemId.js +55 -0
  10. package/dist/client/use-i18n.d.ts +7 -0
  11. package/dist/client/use-i18n.js +64 -0
  12. package/dist/core/__tests__/component-analyzer.test.d.ts +1 -0
  13. package/dist/core/__tests__/component-analyzer.test.js +151 -0
  14. package/dist/core/__tests__/hydration-manifest.test.d.ts +1 -0
  15. package/dist/core/__tests__/hydration-manifest.test.js +211 -0
  16. package/dist/core/__tests__/jsx.engine.test.d.ts +1 -0
  17. package/dist/core/__tests__/jsx.engine.test.js +118 -0
  18. package/dist/core/app-setup.d.ts +7 -0
  19. package/dist/core/app-setup.js +79 -0
  20. package/dist/core/auto-register.module.d.ts +9 -0
  21. package/dist/core/auto-register.module.js +18 -0
  22. package/dist/core/auto-wrap-middleware.d.ts +4 -0
  23. package/dist/core/auto-wrap-middleware.js +130 -0
  24. package/dist/core/client-component-wrapper.d.ts +5 -0
  25. package/dist/core/client-component-wrapper.js +37 -0
  26. package/dist/core/client-hydration.d.ts +2 -0
  27. package/dist/core/client-hydration.js +93 -0
  28. package/dist/core/client-wrapper-browser.d.ts +2 -0
  29. package/dist/core/client-wrapper-browser.js +22 -0
  30. package/dist/core/component-analyzer.d.ts +4 -0
  31. package/dist/core/component-analyzer.js +98 -0
  32. package/dist/core/component-auto-wrapper.d.ts +2 -0
  33. package/dist/core/component-auto-wrapper.js +63 -0
  34. package/dist/core/component-client-wrapper.d.ts +4 -0
  35. package/dist/core/component-client-wrapper.js +80 -0
  36. package/dist/core/hydration-generator.d.ts +2 -0
  37. package/dist/core/hydration-generator.js +98 -0
  38. package/dist/core/hydration-manifest.d.ts +7 -0
  39. package/dist/core/hydration-manifest.js +83 -0
  40. package/dist/core/hydration.d.ts +16 -0
  41. package/dist/core/hydration.js +72 -0
  42. package/dist/core/jsx.engine.d.ts +9 -0
  43. package/dist/core/jsx.engine.js +161 -0
  44. package/dist/core/live-reload-client.js +32 -0
  45. package/dist/core/live-reload.controller.d.ts +10 -0
  46. package/dist/core/live-reload.controller.js +38 -0
  47. package/dist/core/navigation.service.d.ts +18 -0
  48. package/dist/core/navigation.service.js +206 -0
  49. package/dist/core/router.module.d.ts +2 -0
  50. package/dist/core/router.module.js +21 -0
  51. package/dist/core/static-assets.controller.d.ts +4 -0
  52. package/dist/core/static-assets.controller.js +51 -0
  53. package/dist/core/types/nav.types.d.ts +22 -0
  54. package/dist/core/types/nav.types.js +2 -0
  55. package/dist/core/views/layout.d.ts +8 -0
  56. package/dist/core/views/layout.js +35 -0
  57. package/dist/decorators/jsx.decorator.d.ts +26 -0
  58. package/dist/decorators/jsx.decorator.js +10 -0
  59. package/dist/decorators/layout.decorator.d.ts +4 -0
  60. package/dist/decorators/layout.decorator.js +29 -0
  61. package/dist/i18n/__tests__/i18n.helper.test.d.ts +1 -0
  62. package/dist/i18n/__tests__/i18n.helper.test.js +105 -0
  63. package/dist/i18n/__tests__/i18n.interceptor.test.d.ts +1 -0
  64. package/dist/i18n/__tests__/i18n.interceptor.test.js +195 -0
  65. package/dist/i18n/__tests__/i18n.module.test.d.ts +1 -0
  66. package/dist/i18n/__tests__/i18n.module.test.js +83 -0
  67. package/dist/i18n/__tests__/i18n.service.test.d.ts +1 -0
  68. package/dist/i18n/__tests__/i18n.service.test.js +109 -0
  69. package/dist/i18n/__tests__/t.test.d.ts +1 -0
  70. package/dist/i18n/__tests__/t.test.js +66 -0
  71. package/dist/i18n/i18n-module.options.d.ts +10 -0
  72. package/dist/i18n/i18n-module.options.js +4 -0
  73. package/dist/i18n/i18n-switcher.controller.d.ts +12 -0
  74. package/dist/i18n/i18n-switcher.controller.js +80 -0
  75. package/dist/i18n/i18n-types.d.ts +8 -0
  76. package/dist/i18n/i18n-types.js +2 -0
  77. package/dist/i18n/i18n.helper.d.ts +14 -0
  78. package/dist/i18n/i18n.helper.js +70 -0
  79. package/dist/i18n/i18n.interceptor.d.ts +9 -0
  80. package/dist/i18n/i18n.interceptor.js +99 -0
  81. package/dist/i18n/i18n.module.d.ts +5 -0
  82. package/dist/i18n/i18n.module.js +51 -0
  83. package/dist/i18n/i18n.service.d.ts +12 -0
  84. package/dist/i18n/i18n.service.js +61 -0
  85. package/dist/i18n/index.d.ts +10 -0
  86. package/dist/i18n/index.js +20 -0
  87. package/dist/i18n/locale.decorator.d.ts +1 -0
  88. package/dist/i18n/locale.decorator.js +8 -0
  89. package/dist/i18n/t.d.ts +3 -0
  90. package/dist/i18n/t.js +16 -0
  91. package/dist/index.d.ts +19 -0
  92. package/dist/index.js +40 -0
  93. package/package.json +79 -0
  94. package/scripts/analyze-styles.ts +124 -0
  95. package/scripts/auto-wrap-exports.ts +239 -0
  96. package/scripts/build-css.ts +38 -0
  97. package/scripts/build-hydration.ts +313 -0
  98. package/scripts/build-page-styles.ts +43 -0
  99. package/scripts/copy-assets.ts +34 -0
  100. package/scripts/dev.sh +3 -0
  101. package/scripts/dev.ts +257 -0
  102. package/src/cli.ts +71 -0
  103. package/src/client/Link.tsx +62 -0
  104. package/src/client/__tests__/getActiveItemId.test.ts +49 -0
  105. package/src/client/getActiveItemId.ts +54 -0
  106. package/src/client/use-i18n.ts +111 -0
  107. package/src/core/__tests__/component-analyzer.test.ts +141 -0
  108. package/src/core/__tests__/hydration-manifest.test.ts +223 -0
  109. package/src/core/__tests__/jsx.engine.test.ts +137 -0
  110. package/src/core/app-setup.ts +114 -0
  111. package/src/core/auto-register.module.ts +30 -0
  112. package/src/core/auto-wrap-middleware.ts +165 -0
  113. package/src/core/client-component-wrapper.ts +72 -0
  114. package/src/core/client-hydration.tsx +99 -0
  115. package/src/core/client-wrapper-browser.ts +40 -0
  116. package/src/core/component-analyzer.ts +89 -0
  117. package/src/core/component-auto-wrapper.ts +68 -0
  118. package/src/core/component-client-wrapper.ts +112 -0
  119. package/src/core/hydration-generator.ts +94 -0
  120. package/src/core/hydration-manifest.ts +79 -0
  121. package/src/core/hydration.ts +70 -0
  122. package/src/core/jsx.engine.ts +205 -0
  123. package/src/core/live-reload-client.js +32 -0
  124. package/src/core/live-reload.controller.ts +55 -0
  125. package/src/core/navigation.service.ts +257 -0
  126. package/src/core/router.module.ts +9 -0
  127. package/src/core/static-assets.controller.ts +19 -0
  128. package/src/core/types/nav.types.ts +53 -0
  129. package/src/core/views/layout.tsx +61 -0
  130. package/src/decorators/jsx.decorator.ts +49 -0
  131. package/src/decorators/layout.decorator.ts +66 -0
  132. package/src/i18n/__tests__/i18n.helper.test.ts +126 -0
  133. package/src/i18n/__tests__/i18n.interceptor.test.ts +229 -0
  134. package/src/i18n/__tests__/i18n.module.test.ts +98 -0
  135. package/src/i18n/__tests__/i18n.service.test.ts +129 -0
  136. package/src/i18n/__tests__/t.test.ts +88 -0
  137. package/src/i18n/i18n-module.options.ts +53 -0
  138. package/src/i18n/i18n-switcher.controller.ts +99 -0
  139. package/src/i18n/i18n-types.ts +56 -0
  140. package/src/i18n/i18n.helper.ts +75 -0
  141. package/src/i18n/i18n.interceptor.ts +114 -0
  142. package/src/i18n/i18n.module.ts +45 -0
  143. package/src/i18n/i18n.service.ts +95 -0
  144. package/src/i18n/index.ts +37 -0
  145. package/src/i18n/locale.decorator.ts +10 -0
  146. package/src/i18n/t.ts +62 -0
  147. 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
+ }