@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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { spawn } = require("child_process");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const command = process.argv[2];
|
|
7
|
+
const args = process.argv.slice(3);
|
|
8
|
+
|
|
9
|
+
const scripts: Record<string, string> = {
|
|
10
|
+
"build-hydration": path.join(__dirname, "../scripts/build-hydration.ts"),
|
|
11
|
+
"auto-wrap": path.join(__dirname, "../scripts/auto-wrap-exports.ts"),
|
|
12
|
+
"build-styles": path.join(__dirname, "../scripts/build-page-styles.ts"),
|
|
13
|
+
dev: path.join(__dirname, "../scripts/dev.ts"),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
if (!command || !scripts[command]) {
|
|
17
|
+
console.error("Usage: harpy <command>");
|
|
18
|
+
console.error("Commands: build-hydration, auto-wrap, build-styles, dev");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const scriptPath = scripts[command];
|
|
23
|
+
|
|
24
|
+
// Find tsx in node_modules - check multiple possible locations
|
|
25
|
+
const findTsx = (): string => {
|
|
26
|
+
const fs = require("fs");
|
|
27
|
+
const possiblePaths = [
|
|
28
|
+
path.join(process.cwd(), "node_modules", ".bin", "tsx"),
|
|
29
|
+
path.join(process.cwd(), "apps", "test-app", "node_modules", ".bin", "tsx"),
|
|
30
|
+
path.join(__dirname, "../../node_modules", ".bin", "tsx"),
|
|
31
|
+
path.join(__dirname, "../../../node_modules", ".bin", "tsx"),
|
|
32
|
+
path.join(__dirname, "../../../../node_modules", ".bin", "tsx"),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
for (const tsxPath of possiblePaths) {
|
|
36
|
+
if (fs.existsSync(tsxPath)) {
|
|
37
|
+
return tsxPath;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// If tsx not found anywhere, throw error with helpful message
|
|
42
|
+
throw new Error(
|
|
43
|
+
"tsx not found. Please install tsx in your project: npm install -D tsx",
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const tsxCmd = findTsx();
|
|
48
|
+
|
|
49
|
+
// Handle "node --import" or "node --loader" commands
|
|
50
|
+
let execCommand: string;
|
|
51
|
+
let cmdArgs: string[];
|
|
52
|
+
|
|
53
|
+
if (tsxCmd.startsWith("node ")) {
|
|
54
|
+
// Split node command and its flags
|
|
55
|
+
const parts = tsxCmd.split(" ");
|
|
56
|
+
execCommand = parts[0]; // 'node'
|
|
57
|
+
cmdArgs = [...parts.slice(1), scriptPath, ...args];
|
|
58
|
+
} else {
|
|
59
|
+
// Direct tsx binary
|
|
60
|
+
execCommand = tsxCmd;
|
|
61
|
+
cmdArgs = [scriptPath, ...args];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const proc = spawn(execCommand, cmdArgs, {
|
|
65
|
+
stdio: "inherit",
|
|
66
|
+
shell: false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
proc.on("exit", (code: number | null) => {
|
|
70
|
+
process.exit(code || 0);
|
|
71
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { AnchorHTMLAttributes, MouseEvent, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
export type LinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
|
|
4
|
+
replace?: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A drop-in replacement for an `<a>` tag that performs client-side
|
|
9
|
+
* navigation for same-origin/internal links and falls back to a normal
|
|
10
|
+
* anchor for external links or when modifier keys / non-primary buttons
|
|
11
|
+
* are used. This keeps behaviour identical to a native `<a>` while
|
|
12
|
+
* enabling SPA-style history navigation when appropriate.
|
|
13
|
+
*/
|
|
14
|
+
export default function Link({
|
|
15
|
+
href = "#",
|
|
16
|
+
onClick,
|
|
17
|
+
replace,
|
|
18
|
+
...rest
|
|
19
|
+
}: LinkProps) {
|
|
20
|
+
const isLocal = typeof href === "string" && href.startsWith("/");
|
|
21
|
+
|
|
22
|
+
const handleClick = useCallback(
|
|
23
|
+
(e: MouseEvent<HTMLAnchorElement>) => {
|
|
24
|
+
if (onClick) {
|
|
25
|
+
onClick(e as unknown as any);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// If the event was already handled by a consumer, do nothing.
|
|
29
|
+
if (e.defaultPrevented) return;
|
|
30
|
+
|
|
31
|
+
// Let the browser handle clicks that intend to open a new tab/window
|
|
32
|
+
// or use a non-primary button.
|
|
33
|
+
if (e.button !== 0 || e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// External links: allow browser default.
|
|
38
|
+
if (!isLocal) return;
|
|
39
|
+
|
|
40
|
+
// Prevent full page reload and update history instead.
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const method = replace ? "replaceState" : "pushState";
|
|
45
|
+
// @ts-ignore DOM typings are available in consumer environments
|
|
46
|
+
window.history[method]({}, "", href as string);
|
|
47
|
+
// Inform any listeners (e.g., client-side router) that the location changed.
|
|
48
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// If anything goes wrong, fall back to normal navigation.
|
|
51
|
+
// Do a full navigation as a last resort.
|
|
52
|
+
window.location.assign(href as string);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
[href, isLocal, onClick, replace],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Render a normal anchor for full compatibility (SEO, right-click,
|
|
59
|
+
// middle-click, screen readers). We only intercept clicks when it's
|
|
60
|
+
// safe to do SPA-style navigation.
|
|
61
|
+
return <a href={href} onClick={handleClick} {...rest} />;
|
|
62
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildHrefIndex,
|
|
3
|
+
getActiveItemIdFromIndex,
|
|
4
|
+
getActiveItemIdFromManifest,
|
|
5
|
+
} from '../getActiveItemId';
|
|
6
|
+
|
|
7
|
+
describe('getActiveItemId helpers', () => {
|
|
8
|
+
const items = [
|
|
9
|
+
{ id: 'home', href: '/' },
|
|
10
|
+
{ id: 'docs', href: '/docs' },
|
|
11
|
+
{ id: 'docs-start', href: '/docs/getting-started' },
|
|
12
|
+
{ id: 'about', href: '/about/' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
test('exact match prefers exact href over ancestor', () => {
|
|
16
|
+
const id = getActiveItemIdFromManifest(items, '/docs/getting-started');
|
|
17
|
+
expect(id).toBe('docs-start');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('ancestor matching finds parent when exact missing', () => {
|
|
21
|
+
const id = getActiveItemIdFromManifest(items, '/docs/usage');
|
|
22
|
+
expect(id).toBe('docs');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('normalizes query and fragment and trailing slash', () => {
|
|
26
|
+
const id = getActiveItemIdFromManifest(items, '/about?lang=en#team');
|
|
27
|
+
// item href '/about/' should match '/about?lang=en#team'
|
|
28
|
+
expect(id).toBe('about');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('root fallback matches when no other match', () => {
|
|
32
|
+
const id = getActiveItemIdFromManifest(items, '/unknown/path');
|
|
33
|
+
// root '/' exists and is the fallback in ancestor trimming
|
|
34
|
+
expect(id).toBe('home');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('returns undefined when no match and no root', () => {
|
|
38
|
+
const itemsNoRoot = items.filter((i) => i.href !== '/');
|
|
39
|
+
const id = getActiveItemIdFromManifest(itemsNoRoot, '/unknown/path');
|
|
40
|
+
expect(id).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('index-based lookup returns same result as manifest-based', () => {
|
|
44
|
+
const idx = buildHrefIndex(items);
|
|
45
|
+
const byIndex = getActiveItemIdFromIndex(idx, '/docs/guide/intro');
|
|
46
|
+
const byManifest = getActiveItemIdFromManifest(items, '/docs/guide/intro');
|
|
47
|
+
expect(byIndex).toBe(byManifest);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type NavItemLite = { id: string; href?: string };
|
|
2
|
+
|
|
3
|
+
function normalizePath(p?: string): string {
|
|
4
|
+
if (!p) return "";
|
|
5
|
+
const withoutQuery = p.split(/[?#]/)[0];
|
|
6
|
+
if (withoutQuery.length > 1 && withoutQuery.endsWith("/"))
|
|
7
|
+
return withoutQuery.slice(0, -1);
|
|
8
|
+
return withoutQuery;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildHrefIndex(items: NavItemLite[]) {
|
|
12
|
+
const map = new Map<string, string[]>();
|
|
13
|
+
for (const it of items) {
|
|
14
|
+
if (!it.href) continue;
|
|
15
|
+
const key = normalizePath(it.href);
|
|
16
|
+
const arr = map.get(key) ?? [];
|
|
17
|
+
arr.push(it.id);
|
|
18
|
+
map.set(key, arr);
|
|
19
|
+
}
|
|
20
|
+
return map;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getActiveItemIdFromIndex(
|
|
24
|
+
hrefIndex: Map<string, string[]>,
|
|
25
|
+
currentPath?: string,
|
|
26
|
+
): string | undefined {
|
|
27
|
+
if (!currentPath) return undefined;
|
|
28
|
+
let cur = normalizePath(currentPath);
|
|
29
|
+
while (cur !== "") {
|
|
30
|
+
const entry = hrefIndex.get(cur);
|
|
31
|
+
if (entry && entry.length > 0) return entry[0];
|
|
32
|
+
const lastSlash = cur.lastIndexOf("/");
|
|
33
|
+
if (lastSlash === -1) break;
|
|
34
|
+
if (lastSlash === 0) {
|
|
35
|
+
cur = "/";
|
|
36
|
+
} else {
|
|
37
|
+
cur = cur.slice(0, lastSlash);
|
|
38
|
+
}
|
|
39
|
+
if (cur === "/") {
|
|
40
|
+
const entryRoot = hrefIndex.get("/");
|
|
41
|
+
if (entryRoot && entryRoot.length > 0) return entryRoot[0];
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getActiveItemIdFromManifest(
|
|
49
|
+
items: NavItemLite[],
|
|
50
|
+
currentPath?: string,
|
|
51
|
+
): string | undefined {
|
|
52
|
+
const idx = buildHrefIndex(items);
|
|
53
|
+
return getActiveItemIdFromIndex(idx, currentPath);
|
|
54
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React hook for i18n locale switching (Client-side only)
|
|
3
|
+
*
|
|
4
|
+
* This hook provides a simple way to switch locales from any component
|
|
5
|
+
* (buttons, dropdowns, selects, etc.)
|
|
6
|
+
*
|
|
7
|
+
* The hook updates the URL with the selected locale and reloads the page.
|
|
8
|
+
* The server-side interceptor will automatically set the cookie based on the URL.
|
|
9
|
+
*
|
|
10
|
+
* When used in a component, it automatically injects the language-aware navigation script.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { useI18n } from '@harpy-js/core/client';
|
|
15
|
+
*
|
|
16
|
+
* function MyComponent() {
|
|
17
|
+
* const { switchLocale } = useI18n();
|
|
18
|
+
* return <button onClick={() => switchLocale('fr')}>French</button>;
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
"use client";
|
|
24
|
+
|
|
25
|
+
import { useEffect } from "react";
|
|
26
|
+
|
|
27
|
+
interface UseI18nReturn {
|
|
28
|
+
switchLocale: (locale: string) => void;
|
|
29
|
+
getCurrentLocale: () => string | null;
|
|
30
|
+
buildUrl: (path: string, locale?: string) => string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useI18n(): UseI18nReturn {
|
|
34
|
+
// Register that this component uses i18n, so the navigation script gets injected
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
// Mark that i18n is being used in this render
|
|
37
|
+
if (
|
|
38
|
+
typeof window !== "undefined" &&
|
|
39
|
+
!(window as any).__HARPY_I18N_INITIALIZED__
|
|
40
|
+
) {
|
|
41
|
+
(window as any).__HARPY_I18N_INITIALIZED__ = true;
|
|
42
|
+
|
|
43
|
+
// Inject language-aware navigation script
|
|
44
|
+
const script = document.createElement("script");
|
|
45
|
+
script.textContent = `
|
|
46
|
+
(function() {
|
|
47
|
+
if (window.__HARPY_I18N_NAV_INSTALLED__) return;
|
|
48
|
+
window.__HARPY_I18N_NAV_INSTALLED__ = true;
|
|
49
|
+
|
|
50
|
+
document.addEventListener('click', function(e) {
|
|
51
|
+
var target = e.target;
|
|
52
|
+
while (target && target.tagName !== 'A') {
|
|
53
|
+
target = target.parentElement;
|
|
54
|
+
}
|
|
55
|
+
if (target && target.tagName === 'A' && target.href) {
|
|
56
|
+
var url = new URL(target.href, window.location.origin);
|
|
57
|
+
var currentLang = new URLSearchParams(window.location.search).get('lang');
|
|
58
|
+
if (currentLang && url.origin === window.location.origin && !url.searchParams.has('lang')) {
|
|
59
|
+
url.searchParams.set('lang', currentLang);
|
|
60
|
+
target.href = url.toString();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
})();
|
|
65
|
+
`;
|
|
66
|
+
document.head.appendChild(script);
|
|
67
|
+
}
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Switch to a new locale by updating the URL and reloading the page
|
|
72
|
+
*/
|
|
73
|
+
const switchLocale = (locale: string): void => {
|
|
74
|
+
if (typeof window === "undefined") return;
|
|
75
|
+
|
|
76
|
+
const url = new URL(window.location.href);
|
|
77
|
+
url.searchParams.set("lang", locale);
|
|
78
|
+
window.location.href = url.toString();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get the current locale from the URL
|
|
83
|
+
*/
|
|
84
|
+
const getCurrentLocale = (): string | null => {
|
|
85
|
+
if (typeof window === "undefined") return null;
|
|
86
|
+
|
|
87
|
+
const url = new URL(window.location.href);
|
|
88
|
+
return url.searchParams.get("lang");
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build a URL with the current locale preserved
|
|
93
|
+
* Useful for navigation links that should maintain the language
|
|
94
|
+
*/
|
|
95
|
+
const buildUrl = (path: string, locale?: string): string => {
|
|
96
|
+
if (typeof window === "undefined") return path;
|
|
97
|
+
|
|
98
|
+
const currentLocale = locale || getCurrentLocale();
|
|
99
|
+
if (!currentLocale) return path;
|
|
100
|
+
|
|
101
|
+
const url = new URL(path, window.location.origin);
|
|
102
|
+
url.searchParams.set("lang", currentLocale);
|
|
103
|
+
return url.pathname + url.search;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
switchLocale,
|
|
108
|
+
getCurrentLocale,
|
|
109
|
+
buildUrl,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
jest.mock("fs");
|
|
5
|
+
jest.mock("path");
|
|
6
|
+
|
|
7
|
+
describe("Component Analyzer", () => {
|
|
8
|
+
const mockFs = fs as jest.Mocked<typeof fs>;
|
|
9
|
+
const mockPath = path as jest.Mocked<typeof path>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
jest.clearAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("client component detection", () => {
|
|
16
|
+
it('should detect "use client" directive', () => {
|
|
17
|
+
const code = '"use client";\nexport function MyComponent() {}';
|
|
18
|
+
expect(code).toContain("use client");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should detect server components (no directive)", () => {
|
|
22
|
+
const code = "export function ServerComponent() {}";
|
|
23
|
+
expect(code).not.toContain("use client");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should handle single quotes", () => {
|
|
27
|
+
const code = "'use client';\nexport function Comp() {}";
|
|
28
|
+
expect(code).toMatch(/['"]use client['"]/);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("React hooks detection", () => {
|
|
33
|
+
it("should detect useState import", () => {
|
|
34
|
+
const code = 'import { useState } from "react";';
|
|
35
|
+
expect(code).toContain("useState");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should detect useEffect import", () => {
|
|
39
|
+
const code = 'import { useEffect } from "react";';
|
|
40
|
+
expect(code).toContain("useEffect");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should detect multiple hooks", () => {
|
|
44
|
+
const code = 'import { useState, useEffect, useRef } from "react";';
|
|
45
|
+
expect(code).toContain("useState");
|
|
46
|
+
expect(code).toContain("useEffect");
|
|
47
|
+
expect(code).toContain("useRef");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("event handler detection", () => {
|
|
52
|
+
it("should detect onClick handler", () => {
|
|
53
|
+
const code = "<button onClick={handleClick}>Click</button>";
|
|
54
|
+
expect(code).toContain("onClick");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("should detect onChange handler", () => {
|
|
58
|
+
const code = "<input onChange={handleChange} />";
|
|
59
|
+
expect(code).toContain("onChange");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should detect onSubmit handler", () => {
|
|
63
|
+
const code = "<form onSubmit={handleSubmit}>";
|
|
64
|
+
expect(code).toContain("onSubmit");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("component name extraction", () => {
|
|
69
|
+
it("should extract function component name", () => {
|
|
70
|
+
const code = "export function MyComponent() {}";
|
|
71
|
+
const match = code.match(/function\s+(\w+)/);
|
|
72
|
+
expect(match).toBeDefined();
|
|
73
|
+
expect(match![1]).toBe("MyComponent");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should extract arrow function component name", () => {
|
|
77
|
+
const code = "export const MyComponent = () => {}";
|
|
78
|
+
const match = code.match(/const\s+(\w+)\s*=/);
|
|
79
|
+
expect(match).toBeDefined();
|
|
80
|
+
expect(match![1]).toBe("MyComponent");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should extract default export name", () => {
|
|
84
|
+
const code = "export default function Component() {}";
|
|
85
|
+
const match = code.match(/default\s+function\s+(\w+)/);
|
|
86
|
+
expect(match).toBeDefined();
|
|
87
|
+
expect(match![1]).toBe("Component");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("file system operations", () => {
|
|
92
|
+
it("should filter TypeScript files", () => {
|
|
93
|
+
const files = ["component.tsx", "utils.ts", "styles.css", "test.jsx"];
|
|
94
|
+
const tsxFiles = files.filter(
|
|
95
|
+
(f) => f.endsWith(".tsx") || f.endsWith(".jsx"),
|
|
96
|
+
);
|
|
97
|
+
expect(tsxFiles).toEqual(["component.tsx", "test.jsx"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should handle path operations", () => {
|
|
101
|
+
const basePath = "/src";
|
|
102
|
+
const fileName = "components";
|
|
103
|
+
const fullPath = `${basePath}/${fileName}`;
|
|
104
|
+
expect(fullPath).toBe("/src/components");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("code analysis patterns", () => {
|
|
109
|
+
it("should identify React imports", () => {
|
|
110
|
+
const patterns = [
|
|
111
|
+
'import React from "react"',
|
|
112
|
+
'import * as React from "react"',
|
|
113
|
+
'import { Component } from "react"',
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
patterns.forEach((pattern) => {
|
|
117
|
+
expect(pattern).toMatch(/import.*from\s+['"]react['"]/);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("should identify JSX syntax", () => {
|
|
122
|
+
const jsxPatterns = [
|
|
123
|
+
"<div>content</div>",
|
|
124
|
+
'<Component prop="value" />',
|
|
125
|
+
"<>{children}</>",
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
jsxPatterns.forEach((pattern) => {
|
|
129
|
+
expect(pattern).toMatch(/<[\w>]/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should detect client-side APIs", () => {
|
|
134
|
+
const clientAPIs = ["window", "document", "localStorage", "navigator"];
|
|
135
|
+
const code = "const width = window.innerWidth;";
|
|
136
|
+
|
|
137
|
+
const hasClientAPI = clientAPIs.some((api) => code.includes(api));
|
|
138
|
+
expect(hasClientAPI).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
});
|