@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,61 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { JsxLayoutProps } from "../jsx.engine";
|
|
3
|
+
|
|
4
|
+
export default function DefaultLayout({
|
|
5
|
+
children,
|
|
6
|
+
meta,
|
|
7
|
+
hydrationScripts,
|
|
8
|
+
}: JsxLayoutProps & {
|
|
9
|
+
hydrationScripts?: Array<{ componentName: string; path: string }>;
|
|
10
|
+
}) {
|
|
11
|
+
const title = meta?.title ?? "My App";
|
|
12
|
+
const description = meta?.description ?? "Default app description";
|
|
13
|
+
const canonical = meta?.canonical ?? "https://example.com";
|
|
14
|
+
|
|
15
|
+
const og = meta?.openGraph ?? {};
|
|
16
|
+
const twitter = meta?.twitter ?? {};
|
|
17
|
+
|
|
18
|
+
// Use hydration scripts passed from the engine if available
|
|
19
|
+
const chunkScripts = hydrationScripts || [];
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<html lang="en">
|
|
23
|
+
<head>
|
|
24
|
+
<title>{title}</title>
|
|
25
|
+
<meta charSet="utf-8" />
|
|
26
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
27
|
+
<link rel="stylesheet" href="/styles/styles.css" />
|
|
28
|
+
|
|
29
|
+
<meta name="description" content={description} />
|
|
30
|
+
<link rel="canonical" href={canonical} />
|
|
31
|
+
|
|
32
|
+
{/* Open Graph tags */}
|
|
33
|
+
{og.title && <meta property="og:title" content={og.title} />}
|
|
34
|
+
{og.description && (
|
|
35
|
+
<meta property="og:description" content={og.description} />
|
|
36
|
+
)}
|
|
37
|
+
{og.type && <meta property="og:type" content={og.type} />}
|
|
38
|
+
{og.image && <meta property="og:image" content={og.image} />}
|
|
39
|
+
{og.url && <meta property="og:url" content={og.url} />}
|
|
40
|
+
|
|
41
|
+
{/* Twitter cards */}
|
|
42
|
+
{twitter.card && <meta name="twitter:card" content={twitter.card} />}
|
|
43
|
+
{twitter.title && <meta name="twitter:title" content={twitter.title} />}
|
|
44
|
+
{twitter.description && (
|
|
45
|
+
<meta name="twitter:description" content={twitter.description} />
|
|
46
|
+
)}
|
|
47
|
+
{twitter.image && <meta name="twitter:image" content={twitter.image} />}
|
|
48
|
+
</head>
|
|
49
|
+
<body className="bg-slate-50">
|
|
50
|
+
<main id="body" className="min-h-screen">
|
|
51
|
+
{children}
|
|
52
|
+
</main>
|
|
53
|
+
|
|
54
|
+
{/* Auto-injected hydration scripts at end of body to ensure DOM is ready */}
|
|
55
|
+
{chunkScripts.map((script) => (
|
|
56
|
+
<script key={script.componentName} src={script.path}></script>
|
|
57
|
+
))}
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { RENDER_METADATA } from "@nestjs/common/constants";
|
|
2
|
+
import { JsxLayout } from "../core/jsx.engine";
|
|
3
|
+
|
|
4
|
+
export interface MetaOptions {
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
canonical?: string;
|
|
8
|
+
openGraph?: {
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
type?: string;
|
|
12
|
+
image?: string;
|
|
13
|
+
url?: string;
|
|
14
|
+
};
|
|
15
|
+
twitter?: {
|
|
16
|
+
card?: string;
|
|
17
|
+
title?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
image?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RenderOptions {
|
|
24
|
+
layout?: JsxLayout;
|
|
25
|
+
bootstrapScripts?: string[];
|
|
26
|
+
meta?: MetaResolver;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type MetaResolver<T = any> =
|
|
30
|
+
| MetaOptions
|
|
31
|
+
| ((req: any, data: T) => MetaOptions | Promise<MetaOptions>);
|
|
32
|
+
|
|
33
|
+
export function JsxRender<T>(
|
|
34
|
+
template: (data: T) => React.JSX.Element,
|
|
35
|
+
options: RenderOptions = {},
|
|
36
|
+
): MethodDecorator {
|
|
37
|
+
return (
|
|
38
|
+
target: object,
|
|
39
|
+
key: string | symbol,
|
|
40
|
+
descriptor: TypedPropertyDescriptor<any>,
|
|
41
|
+
) => {
|
|
42
|
+
Reflect.defineMetadata(
|
|
43
|
+
RENDER_METADATA,
|
|
44
|
+
[template, options],
|
|
45
|
+
descriptor.value,
|
|
46
|
+
);
|
|
47
|
+
return descriptor;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { JsxLayout } from "../core/jsx.engine";
|
|
2
|
+
|
|
3
|
+
export const LAYOUT_METADATA = "harpy:layout";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Decorator to specify a custom layout for a controller or route handler
|
|
7
|
+
* @param layout - The layout component to use
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // Apply to entire controller
|
|
11
|
+
* @Controller('auth')
|
|
12
|
+
* @WithLayout(AuthLayout)
|
|
13
|
+
* export class AuthController { }
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // Apply to specific route
|
|
17
|
+
* @Get('profile')
|
|
18
|
+
* @WithLayout(DashboardLayout)
|
|
19
|
+
* @JsxRender(ProfileView)
|
|
20
|
+
* getProfile() { }
|
|
21
|
+
*/
|
|
22
|
+
export function WithLayout(
|
|
23
|
+
layout: JsxLayout,
|
|
24
|
+
): ClassDecorator & MethodDecorator {
|
|
25
|
+
return (
|
|
26
|
+
target: any,
|
|
27
|
+
propertyKey?: string | symbol,
|
|
28
|
+
descriptor?: PropertyDescriptor,
|
|
29
|
+
) => {
|
|
30
|
+
if (propertyKey && descriptor) {
|
|
31
|
+
// Method decorator - applied to route handler
|
|
32
|
+
Reflect.defineMetadata(LAYOUT_METADATA, layout, descriptor.value);
|
|
33
|
+
return descriptor;
|
|
34
|
+
} else {
|
|
35
|
+
// Class decorator - applied to controller
|
|
36
|
+
Reflect.defineMetadata(LAYOUT_METADATA, layout, target);
|
|
37
|
+
return target;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Get the layout for a specific handler or controller
|
|
44
|
+
*/
|
|
45
|
+
export function getLayoutForHandler(
|
|
46
|
+
handler: Function,
|
|
47
|
+
controllerClass: Function,
|
|
48
|
+
): JsxLayout | undefined {
|
|
49
|
+
// Check method-level layout first (highest priority)
|
|
50
|
+
const methodLayout = Reflect.getMetadata(LAYOUT_METADATA, handler);
|
|
51
|
+
if (methodLayout) {
|
|
52
|
+
return methodLayout;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Fall back to controller-level layout
|
|
56
|
+
const controllerLayout = Reflect.getMetadata(
|
|
57
|
+
LAYOUT_METADATA,
|
|
58
|
+
controllerClass,
|
|
59
|
+
);
|
|
60
|
+
if (controllerLayout) {
|
|
61
|
+
return controllerLayout;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// No layout specified
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { Test, TestingModule } from "@nestjs/testing";
|
|
2
|
+
import { I18nHelper } from "../i18n.helper";
|
|
3
|
+
import { I18N_MODULE_OPTIONS } from "../i18n-module.options";
|
|
4
|
+
import type { I18nModuleOptions } from "../i18n-module.options";
|
|
5
|
+
|
|
6
|
+
describe("I18nHelper", () => {
|
|
7
|
+
let helper: I18nHelper;
|
|
8
|
+
const mockOptions: I18nModuleOptions = {
|
|
9
|
+
defaultLocale: "en",
|
|
10
|
+
locales: ["en", "fr", "es"],
|
|
11
|
+
urlPattern: "query",
|
|
12
|
+
translationsPath: "./dictionaries",
|
|
13
|
+
cookieName: "locale",
|
|
14
|
+
queryParam: "lang",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
19
|
+
providers: [
|
|
20
|
+
I18nHelper,
|
|
21
|
+
{
|
|
22
|
+
provide: I18N_MODULE_OPTIONS,
|
|
23
|
+
useValue: mockOptions,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
}).compile();
|
|
27
|
+
|
|
28
|
+
helper = module.get<I18nHelper>(I18nHelper);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("buildLocaleUrl", () => {
|
|
32
|
+
describe("query pattern", () => {
|
|
33
|
+
it("should build URL with query parameter", () => {
|
|
34
|
+
const url = helper.buildLocaleUrl("fr", "/home");
|
|
35
|
+
expect(url).toBe("/home?lang=fr");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should preserve existing query parameters", () => {
|
|
39
|
+
const url = helper.buildLocaleUrl("fr", "/home", { page: "2" });
|
|
40
|
+
expect(url).toContain("lang=fr");
|
|
41
|
+
expect(url).toContain("page=2");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should replace existing lang parameter", () => {
|
|
45
|
+
const url = helper.buildLocaleUrl("fr", "/home", { lang: "en" });
|
|
46
|
+
expect(url).toBe("/home?lang=fr");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("header pattern", () => {
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
53
|
+
providers: [
|
|
54
|
+
I18nHelper,
|
|
55
|
+
{
|
|
56
|
+
provide: I18N_MODULE_OPTIONS,
|
|
57
|
+
useValue: { ...mockOptions, urlPattern: "header" },
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}).compile();
|
|
61
|
+
|
|
62
|
+
helper = module.get<I18nHelper>(I18nHelper);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should return path without query for header pattern", () => {
|
|
66
|
+
const url = helper.buildLocaleUrl("fr", "/home");
|
|
67
|
+
expect(url).toBe("/home");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should preserve existing query parameters", () => {
|
|
71
|
+
const url = helper.buildLocaleUrl("fr", "/home", { page: "2" });
|
|
72
|
+
expect(url).toBe("/home?page=2");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("setLocaleCookie", () => {
|
|
78
|
+
it("should set cookie with correct name and value", () => {
|
|
79
|
+
const mockRes = {
|
|
80
|
+
setCookie: jest.fn(),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
helper.setLocaleCookie(mockRes, "fr");
|
|
84
|
+
|
|
85
|
+
expect(mockRes.setCookie).toHaveBeenCalledWith(
|
|
86
|
+
"locale",
|
|
87
|
+
"fr",
|
|
88
|
+
expect.objectContaining({
|
|
89
|
+
path: "/",
|
|
90
|
+
maxAge: 365 * 24 * 60 * 60,
|
|
91
|
+
httpOnly: false,
|
|
92
|
+
sameSite: "lax",
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("validateLocale", () => {
|
|
99
|
+
it("should return valid locale", () => {
|
|
100
|
+
expect(helper.validateLocale("fr")).toBe("fr");
|
|
101
|
+
expect(helper.validateLocale("es")).toBe("es");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("should return default locale for invalid locale", () => {
|
|
105
|
+
expect(helper.validateLocale("de")).toBe("en");
|
|
106
|
+
expect(helper.validateLocale("invalid")).toBe("en");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should return default locale for empty string", () => {
|
|
110
|
+
expect(helper.validateLocale("")).toBe("en");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("getClientConfig", () => {
|
|
115
|
+
it("should return client configuration", () => {
|
|
116
|
+
const config = helper.getClientConfig();
|
|
117
|
+
|
|
118
|
+
expect(config).toEqual({
|
|
119
|
+
locales: ["en", "fr", "es"],
|
|
120
|
+
defaultLocale: "en",
|
|
121
|
+
urlPattern: "query",
|
|
122
|
+
queryParam: "lang",
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { Test, TestingModule } from "@nestjs/testing";
|
|
2
|
+
import { ExecutionContext, CallHandler } from "@nestjs/common";
|
|
3
|
+
import { I18nInterceptor } from "../i18n.interceptor";
|
|
4
|
+
import { I18N_MODULE_OPTIONS } from "../i18n-module.options";
|
|
5
|
+
import type { I18nModuleOptions } from "../i18n-module.options";
|
|
6
|
+
import { of } from "rxjs";
|
|
7
|
+
|
|
8
|
+
describe("I18nInterceptor", () => {
|
|
9
|
+
let interceptor: I18nInterceptor;
|
|
10
|
+
const mockOptions: I18nModuleOptions = {
|
|
11
|
+
defaultLocale: "en",
|
|
12
|
+
locales: ["en", "fr", "es"],
|
|
13
|
+
urlPattern: "query",
|
|
14
|
+
translationsPath: "./dictionaries",
|
|
15
|
+
cookieName: "locale",
|
|
16
|
+
queryParam: "lang",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
21
|
+
providers: [
|
|
22
|
+
I18nInterceptor,
|
|
23
|
+
{
|
|
24
|
+
provide: I18N_MODULE_OPTIONS,
|
|
25
|
+
useValue: mockOptions,
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
}).compile();
|
|
29
|
+
|
|
30
|
+
interceptor = module.get<I18nInterceptor>(I18nInterceptor);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const createMockContext = (
|
|
34
|
+
request: any,
|
|
35
|
+
response?: any,
|
|
36
|
+
): ExecutionContext => {
|
|
37
|
+
const mockResponse = response || {
|
|
38
|
+
header: jest.fn(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
switchToHttp: () => ({
|
|
43
|
+
getRequest: () => request,
|
|
44
|
+
getResponse: () => mockResponse,
|
|
45
|
+
getNext: jest.fn(),
|
|
46
|
+
}),
|
|
47
|
+
} as any;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const createMockCallHandler = (): CallHandler => {
|
|
51
|
+
return {
|
|
52
|
+
handle: () => of({}),
|
|
53
|
+
} as any;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
describe("Query pattern locale detection", () => {
|
|
57
|
+
it("should extract locale from query parameter", (done) => {
|
|
58
|
+
const mockRequest: any = {
|
|
59
|
+
query: { lang: "fr" },
|
|
60
|
+
headers: {},
|
|
61
|
+
};
|
|
62
|
+
const context = createMockContext(mockRequest);
|
|
63
|
+
const next = createMockCallHandler();
|
|
64
|
+
|
|
65
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
66
|
+
expect(mockRequest.locale).toBe("fr");
|
|
67
|
+
done();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("should ignore invalid query locale", (done) => {
|
|
72
|
+
const mockRequest: any = {
|
|
73
|
+
query: { lang: "invalid" },
|
|
74
|
+
headers: {},
|
|
75
|
+
};
|
|
76
|
+
const context = createMockContext(mockRequest);
|
|
77
|
+
const next = createMockCallHandler();
|
|
78
|
+
|
|
79
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
80
|
+
expect(mockRequest.locale).toBe("en");
|
|
81
|
+
done();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("Cookie locale detection", () => {
|
|
87
|
+
it("should extract locale from cookie", (done) => {
|
|
88
|
+
const mockRequest: any = {
|
|
89
|
+
query: {},
|
|
90
|
+
headers: {
|
|
91
|
+
cookie: "locale=fr; other=value",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const context = createMockContext(mockRequest);
|
|
95
|
+
const next = createMockCallHandler();
|
|
96
|
+
|
|
97
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
98
|
+
expect(mockRequest.locale).toBe("fr");
|
|
99
|
+
done();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should ignore invalid cookie locale", (done) => {
|
|
104
|
+
const mockRequest: any = {
|
|
105
|
+
query: {},
|
|
106
|
+
headers: {
|
|
107
|
+
cookie: "locale=invalid",
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const context = createMockContext(mockRequest);
|
|
111
|
+
const next = createMockCallHandler();
|
|
112
|
+
|
|
113
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
114
|
+
expect(mockRequest.locale).toBe("en");
|
|
115
|
+
done();
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("Header locale detection", () => {
|
|
121
|
+
it("should extract locale from x-lang header when using header pattern", (done) => {
|
|
122
|
+
// Create interceptor with header pattern
|
|
123
|
+
const headerInterceptor = new I18nInterceptor({
|
|
124
|
+
...mockOptions,
|
|
125
|
+
urlPattern: "header",
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const mockRequest: any = {
|
|
129
|
+
query: {},
|
|
130
|
+
headers: {
|
|
131
|
+
"x-lang": "fr",
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
const context = createMockContext(mockRequest);
|
|
135
|
+
const next = createMockCallHandler();
|
|
136
|
+
|
|
137
|
+
headerInterceptor.intercept(context, next).subscribe(() => {
|
|
138
|
+
expect(mockRequest.locale).toBe("fr");
|
|
139
|
+
done();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should extract locale from accept-language header as fallback", (done) => {
|
|
144
|
+
const mockRequest: any = {
|
|
145
|
+
query: {},
|
|
146
|
+
headers: {
|
|
147
|
+
"accept-language": "fr-FR,fr;q=0.9,en;q=0.8",
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
const context = createMockContext(mockRequest);
|
|
151
|
+
const next = createMockCallHandler();
|
|
152
|
+
|
|
153
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
154
|
+
expect(mockRequest.locale).toBe("fr");
|
|
155
|
+
done();
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("Locale priority", () => {
|
|
161
|
+
it("should prioritize query over cookie", (done) => {
|
|
162
|
+
const mockRequest: any = {
|
|
163
|
+
query: { lang: "es" },
|
|
164
|
+
headers: {
|
|
165
|
+
cookie: "locale=fr",
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
const context = createMockContext(mockRequest);
|
|
169
|
+
const next = createMockCallHandler();
|
|
170
|
+
|
|
171
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
172
|
+
expect(mockRequest.locale).toBe("es");
|
|
173
|
+
done();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("should prioritize cookie over accept-language header", (done) => {
|
|
178
|
+
const mockRequest: any = {
|
|
179
|
+
query: {},
|
|
180
|
+
headers: {
|
|
181
|
+
cookie: "locale=fr",
|
|
182
|
+
"accept-language": "es-ES,es;q=0.9,en;q=0.8",
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
const context = createMockContext(mockRequest);
|
|
186
|
+
const next = createMockCallHandler();
|
|
187
|
+
|
|
188
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
189
|
+
expect(mockRequest.locale).toBe("fr");
|
|
190
|
+
done();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("should fall back to default locale when nothing matches", (done) => {
|
|
195
|
+
const mockRequest: any = {
|
|
196
|
+
query: {},
|
|
197
|
+
headers: {},
|
|
198
|
+
};
|
|
199
|
+
const context = createMockContext(mockRequest);
|
|
200
|
+
const next = createMockCallHandler();
|
|
201
|
+
|
|
202
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
203
|
+
expect(mockRequest.locale).toBe("en");
|
|
204
|
+
done();
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("should set cookie when locale is found in URL", (done) => {
|
|
209
|
+
const mockResponse = {
|
|
210
|
+
header: jest.fn(),
|
|
211
|
+
};
|
|
212
|
+
const mockRequest: any = {
|
|
213
|
+
query: { lang: "fr" },
|
|
214
|
+
headers: {},
|
|
215
|
+
};
|
|
216
|
+
const context = createMockContext(mockRequest, mockResponse);
|
|
217
|
+
const next = createMockCallHandler();
|
|
218
|
+
|
|
219
|
+
interceptor.intercept(context, next).subscribe(() => {
|
|
220
|
+
expect(mockRequest.locale).toBe("fr");
|
|
221
|
+
expect(mockResponse.header).toHaveBeenCalledWith(
|
|
222
|
+
"Set-Cookie",
|
|
223
|
+
expect.stringContaining("locale=fr"),
|
|
224
|
+
);
|
|
225
|
+
done();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Test, TestingModule } from "@nestjs/testing";
|
|
2
|
+
import { I18nModule } from "../i18n.module";
|
|
3
|
+
import { I18nService } from "../i18n.service";
|
|
4
|
+
import { I18nHelper } from "../i18n.helper";
|
|
5
|
+
import { I18nSwitcherController } from "../i18n-switcher.controller";
|
|
6
|
+
import { I18N_MODULE_OPTIONS } from "../i18n-module.options";
|
|
7
|
+
|
|
8
|
+
describe("I18nModule", () => {
|
|
9
|
+
it("should be defined", () => {
|
|
10
|
+
expect(I18nModule).toBeDefined();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("forRoot", () => {
|
|
14
|
+
it("should create a dynamic module with default options", async () => {
|
|
15
|
+
const module = I18nModule.forRoot({
|
|
16
|
+
defaultLocale: "en",
|
|
17
|
+
locales: ["en", "fr"],
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(module.module).toBe(I18nModule);
|
|
21
|
+
expect(module.controllers).toContain(I18nSwitcherController);
|
|
22
|
+
expect(module.providers).toBeDefined();
|
|
23
|
+
expect(module.exports).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should provide I18nService", async () => {
|
|
27
|
+
const testModule: TestingModule = await Test.createTestingModule({
|
|
28
|
+
imports: [
|
|
29
|
+
I18nModule.forRoot({
|
|
30
|
+
defaultLocale: "en",
|
|
31
|
+
locales: ["en", "fr"],
|
|
32
|
+
}),
|
|
33
|
+
],
|
|
34
|
+
}).compile();
|
|
35
|
+
|
|
36
|
+
const service = await testModule.resolve<I18nService>(I18nService);
|
|
37
|
+
expect(service).toBeDefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("should provide I18nHelper", async () => {
|
|
41
|
+
const testModule: TestingModule = await Test.createTestingModule({
|
|
42
|
+
imports: [
|
|
43
|
+
I18nModule.forRoot({
|
|
44
|
+
defaultLocale: "en",
|
|
45
|
+
locales: ["en", "fr"],
|
|
46
|
+
}),
|
|
47
|
+
],
|
|
48
|
+
}).compile();
|
|
49
|
+
|
|
50
|
+
const helper = testModule.get<I18nHelper>(I18nHelper);
|
|
51
|
+
expect(helper).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should apply default values for missing options", async () => {
|
|
55
|
+
const module = I18nModule.forRoot({
|
|
56
|
+
defaultLocale: "en",
|
|
57
|
+
locales: ["en"],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const optionsProvider = module.providers?.find(
|
|
61
|
+
(p: any) => p.provide === I18N_MODULE_OPTIONS,
|
|
62
|
+
) as any;
|
|
63
|
+
|
|
64
|
+
expect(optionsProvider.useValue).toEqual({
|
|
65
|
+
defaultLocale: "en",
|
|
66
|
+
locales: ["en"],
|
|
67
|
+
urlPattern: "query",
|
|
68
|
+
translationsPath: "./dictionaries",
|
|
69
|
+
cookieName: "locale",
|
|
70
|
+
queryParam: "lang",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("should preserve custom options", async () => {
|
|
75
|
+
const module = I18nModule.forRoot({
|
|
76
|
+
defaultLocale: "fr",
|
|
77
|
+
locales: ["fr", "en", "es"],
|
|
78
|
+
urlPattern: "header",
|
|
79
|
+
translationsPath: "../locales",
|
|
80
|
+
cookieName: "user_locale",
|
|
81
|
+
queryParam: "language",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const optionsProvider = module.providers?.find(
|
|
85
|
+
(p: any) => p.provide === I18N_MODULE_OPTIONS,
|
|
86
|
+
) as any;
|
|
87
|
+
|
|
88
|
+
expect(optionsProvider.useValue).toEqual({
|
|
89
|
+
defaultLocale: "fr",
|
|
90
|
+
locales: ["fr", "en", "es"],
|
|
91
|
+
urlPattern: "header",
|
|
92
|
+
translationsPath: "../locales",
|
|
93
|
+
cookieName: "user_locale",
|
|
94
|
+
queryParam: "language",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|