@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,72 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Generates a unique instance ID (browser-compatible version)
|
|
5
|
+
*/
|
|
6
|
+
function generateInstanceId(prefix: string): string {
|
|
7
|
+
// Use Math.random for browser compatibility
|
|
8
|
+
const randomId = Math.random().toString(36).substring(2, 11);
|
|
9
|
+
return `${prefix}-${randomId}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Global tracker for registering components on the server side
|
|
14
|
+
* This is set by the JSX engine during server-side rendering
|
|
15
|
+
*/
|
|
16
|
+
declare global {
|
|
17
|
+
var __COMPONENT_REGISTRY__: ((data: any) => void) | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Wrapper that enables automatic hydration for components marked with 'use client'.
|
|
22
|
+
*
|
|
23
|
+
* This works on both server and browser:
|
|
24
|
+
* - Server: Creates a hydration container with props and calls the registration function if available
|
|
25
|
+
* - Browser: Creates the same hydration container for client-side hydration
|
|
26
|
+
*/
|
|
27
|
+
export function autoWrapClientComponent<T extends React.ComponentType<any>>(
|
|
28
|
+
Component: T,
|
|
29
|
+
componentName: string,
|
|
30
|
+
): React.ComponentType<React.ComponentProps<T>> {
|
|
31
|
+
const WrappedComponent = (props: React.ComponentProps<T>) => {
|
|
32
|
+
// Generate a unique instance ID for this component instance
|
|
33
|
+
const instanceId = generateInstanceId(`hydrate-${componentName}`);
|
|
34
|
+
|
|
35
|
+
// Register on server side if registry is available
|
|
36
|
+
console.log(
|
|
37
|
+
`[Wrapper] Rendering ${componentName}, registry available:`,
|
|
38
|
+
typeof global !== "undefined" && !!global.__COMPONENT_REGISTRY__,
|
|
39
|
+
);
|
|
40
|
+
if (typeof global !== "undefined" && global.__COMPONENT_REGISTRY__) {
|
|
41
|
+
console.log(
|
|
42
|
+
`[Wrapper] Registering ${componentName} with id ${instanceId}`,
|
|
43
|
+
);
|
|
44
|
+
global.__COMPONENT_REGISTRY__({
|
|
45
|
+
componentPath: "",
|
|
46
|
+
componentName,
|
|
47
|
+
instanceId,
|
|
48
|
+
props: props as Record<string, any>,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Store props in a script tag for client-side hydration access
|
|
53
|
+
const propsJson = JSON.stringify(props);
|
|
54
|
+
|
|
55
|
+
return React.createElement(
|
|
56
|
+
"div",
|
|
57
|
+
{
|
|
58
|
+
id: instanceId,
|
|
59
|
+
suppressHydrationWarning: true,
|
|
60
|
+
},
|
|
61
|
+
React.createElement(Component, props),
|
|
62
|
+
React.createElement("script", {
|
|
63
|
+
type: "application/json",
|
|
64
|
+
id: `${instanceId}-props`,
|
|
65
|
+
dangerouslySetInnerHTML: { __html: propsJson },
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
WrappedComponent.displayName = `ClientComponent(${componentName})`;
|
|
71
|
+
return WrappedComponent;
|
|
72
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side hydration entry point.
|
|
3
|
+
* This script is injected into the HTML and runs in the browser to hydrate interactive components.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Client-side hydration entry point.
|
|
10
|
+
* This script is injected into the HTML and runs in the browser to hydrate interactive components.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface HydrationMarker {
|
|
14
|
+
componentName: string;
|
|
15
|
+
instanceId: string;
|
|
16
|
+
propsId: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Finds all hydration markers in the DOM and returns their data
|
|
21
|
+
*/
|
|
22
|
+
function findHydrationMarkers(): HydrationMarker[] {
|
|
23
|
+
const markers: HydrationMarker[] = [];
|
|
24
|
+
|
|
25
|
+
// Find all divs with id starting with 'hydrate-'
|
|
26
|
+
const hydrationDivs = document.querySelectorAll('div[id^="hydrate-"]');
|
|
27
|
+
|
|
28
|
+
hydrationDivs.forEach((div) => {
|
|
29
|
+
const instanceId = div.id;
|
|
30
|
+
// Parse instanceId: "hydrate-ComponentName-uniqueid"
|
|
31
|
+
const match = instanceId.match(/^hydrate-([^-]+)-(.+)$/);
|
|
32
|
+
|
|
33
|
+
if (match) {
|
|
34
|
+
const componentName = match[1];
|
|
35
|
+
const propsScriptId = `${instanceId}-props`;
|
|
36
|
+
|
|
37
|
+
markers.push({
|
|
38
|
+
componentName,
|
|
39
|
+
instanceId,
|
|
40
|
+
propsId: propsScriptId,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return markers;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Gets props for a hydration marker from the embedded script tag
|
|
50
|
+
*/
|
|
51
|
+
function getHydrationProps(propsId: string): Record<string, any> {
|
|
52
|
+
const propsScript = document.getElementById(propsId);
|
|
53
|
+
if (!propsScript) return {};
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(propsScript.textContent || "{}");
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.warn(`Failed to parse props for ${propsId}:`, e);
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hydrates a single client component
|
|
65
|
+
* This is called by the bundled component chunk scripts
|
|
66
|
+
*/
|
|
67
|
+
export function hydrateComponent(
|
|
68
|
+
componentName: string,
|
|
69
|
+
Component: React.ComponentType<any>,
|
|
70
|
+
) {
|
|
71
|
+
const markers = findHydrationMarkers();
|
|
72
|
+
const relevantMarkers = markers.filter(
|
|
73
|
+
(m) => m.componentName === componentName,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
relevantMarkers.forEach((marker) => {
|
|
77
|
+
const container = document.getElementById(marker.instanceId);
|
|
78
|
+
if (!container) {
|
|
79
|
+
console.warn(`Hydration container not found for ${marker.instanceId}`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const props = getHydrationProps(marker.propsId);
|
|
84
|
+
|
|
85
|
+
// Dynamically import React for hydration
|
|
86
|
+
Promise.all([import("react-dom/client")]).then(([{ hydrateRoot }]) => {
|
|
87
|
+
try {
|
|
88
|
+
hydrateRoot(container, React.createElement(Component, props));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`Failed to hydrate component ${componentName}:`, error);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Export for use in component bundles
|
|
97
|
+
if (typeof window !== "undefined") {
|
|
98
|
+
(window as any).__HYDRATION__ = { hydrateComponent };
|
|
99
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe version of client component wrapper
|
|
3
|
+
* This version is used only for client-side hydration and doesn't import Node modules
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React from "react";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Browser-compatible instance ID generator
|
|
10
|
+
*/
|
|
11
|
+
function generateBrowserInstanceId(prefix: string): string {
|
|
12
|
+
const randomId = Math.random().toString(36).substring(2, 11);
|
|
13
|
+
return `${prefix}-${randomId}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Client-side wrapper for hydration
|
|
18
|
+
* Used during client-side hydration to wrap components
|
|
19
|
+
*/
|
|
20
|
+
export function hydrateClientComponent<T extends React.ComponentType<any>>(
|
|
21
|
+
Component: T,
|
|
22
|
+
componentName: string,
|
|
23
|
+
props: Record<string, any>,
|
|
24
|
+
): React.ReactElement {
|
|
25
|
+
const instanceId = generateBrowserInstanceId(`hydrate-${componentName}`);
|
|
26
|
+
|
|
27
|
+
return React.createElement(
|
|
28
|
+
"div",
|
|
29
|
+
{
|
|
30
|
+
id: instanceId,
|
|
31
|
+
suppressHydrationWarning: true,
|
|
32
|
+
},
|
|
33
|
+
React.createElement(Component, props),
|
|
34
|
+
React.createElement("script", {
|
|
35
|
+
type: "application/json",
|
|
36
|
+
id: `${instanceId}-props`,
|
|
37
|
+
dangerouslySetInnerHTML: { __html: JSON.stringify(props) },
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detects if a component file has the 'use client' directive
|
|
6
|
+
*/
|
|
7
|
+
export function hasUseClientDirective(filePath: string): boolean {
|
|
8
|
+
try {
|
|
9
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
10
|
+
// Match "use client" directive at the start of the file, possibly after comments
|
|
11
|
+
const useClientRegex = /^(['"]use client['"];?\s*)/m;
|
|
12
|
+
return useClientRegex.test(content);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error(`Error reading file ${filePath}:`, error);
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get the component name from file path
|
|
21
|
+
* Example: /src/features/home/views/counter.tsx -> Counter (converted from counter)
|
|
22
|
+
*/
|
|
23
|
+
export function getComponentNameFromPath(filePath: string): string {
|
|
24
|
+
const fileName = path.basename(filePath, path.extname(filePath));
|
|
25
|
+
// Convert kebab-case to PascalCase
|
|
26
|
+
return fileName
|
|
27
|
+
.split("-")
|
|
28
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
29
|
+
.join("");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Recursively find all component files (.tsx/.ts) in a directory
|
|
34
|
+
*/
|
|
35
|
+
export function findComponentFiles(
|
|
36
|
+
dir: string,
|
|
37
|
+
exclude: string[] = [],
|
|
38
|
+
): string[] {
|
|
39
|
+
const components: string[] = [];
|
|
40
|
+
|
|
41
|
+
function traverse(currentPath: string) {
|
|
42
|
+
try {
|
|
43
|
+
const files = fs.readdirSync(currentPath);
|
|
44
|
+
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const fullPath = path.join(currentPath, file);
|
|
47
|
+
const stat = fs.statSync(fullPath);
|
|
48
|
+
|
|
49
|
+
// Skip excluded paths
|
|
50
|
+
if (exclude.some((ex) => fullPath.includes(ex))) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (stat.isDirectory()) {
|
|
55
|
+
traverse(fullPath);
|
|
56
|
+
} else if (file.endsWith(".tsx") || file.endsWith(".ts")) {
|
|
57
|
+
// Only include files that export components (convention: in views/ or end with Component)
|
|
58
|
+
if (
|
|
59
|
+
fullPath.includes("/views/") ||
|
|
60
|
+
file.endsWith(".component.ts") ||
|
|
61
|
+
file.endsWith(".component.tsx")
|
|
62
|
+
) {
|
|
63
|
+
components.push(fullPath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(`Error traversing directory ${currentPath}:`, error);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
traverse(dir);
|
|
73
|
+
return components;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get client components from source directory
|
|
78
|
+
*/
|
|
79
|
+
export function getClientComponents(srcDir: string): string[] {
|
|
80
|
+
const components = findComponentFiles(srcDir, [
|
|
81
|
+
"/node_modules/",
|
|
82
|
+
"/dist/",
|
|
83
|
+
"/test/",
|
|
84
|
+
"/core/", // Exclude internal framework code
|
|
85
|
+
]);
|
|
86
|
+
return components.filter((componentPath) =>
|
|
87
|
+
hasUseClientDirective(componentPath),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-wrapping system for components with 'use client' directive.
|
|
3
|
+
*
|
|
4
|
+
* This module provides utilities to automatically detect and wrap client components
|
|
5
|
+
* without requiring manual withClientComponent() calls in application code.
|
|
6
|
+
*
|
|
7
|
+
* The wrapping happens transparently at the component definition level.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { autoWrapClientComponent } from "./client-component-wrapper";
|
|
13
|
+
import { getComponentNameFromPath } from "./component-analyzer";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Cache of file paths and whether they have 'use client' directive
|
|
17
|
+
* Key: absolute file path, Value: boolean indicating presence of 'use client'
|
|
18
|
+
*/
|
|
19
|
+
const clientComponentCache = new Map<string, boolean>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detects if a source file has 'use client' directive
|
|
23
|
+
*/
|
|
24
|
+
function hasUseClientDirective(filePath: string): boolean {
|
|
25
|
+
if (clientComponentCache.has(filePath)) {
|
|
26
|
+
return clientComponentCache.get(filePath)!;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
31
|
+
// Match "use client" at the very start of the file
|
|
32
|
+
const hasDirective = /^(['"]use client['"];?\s*)/m.test(content);
|
|
33
|
+
clientComponentCache.set(filePath, hasDirective);
|
|
34
|
+
return hasDirective;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
clientComponentCache.set(filePath, false);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Wraps a component if its source file contains 'use client' directive.
|
|
43
|
+
* This is the recommended way to use client components - just export them normally
|
|
44
|
+
* with 'use client' at the top, and they'll be auto-wrapped.
|
|
45
|
+
*
|
|
46
|
+
* Usage:
|
|
47
|
+
* export default autoWrap(MyComponent, import.meta.url);
|
|
48
|
+
*
|
|
49
|
+
* Or better yet, use the custom hook approach in component-client.ts
|
|
50
|
+
*/
|
|
51
|
+
export function autoWrap<T extends React.ComponentType<any>>(
|
|
52
|
+
Component: T,
|
|
53
|
+
fileUrl: string,
|
|
54
|
+
): T {
|
|
55
|
+
// Convert URL to file path
|
|
56
|
+
const filePath = fileUrl.replace("file://", "");
|
|
57
|
+
|
|
58
|
+
// If this component file doesn't have 'use client', return it unwrapped
|
|
59
|
+
if (!hasUseClientDirective(filePath)) {
|
|
60
|
+
return Component;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Get component name from file path
|
|
64
|
+
const componentName = getComponentNameFromPath(filePath);
|
|
65
|
+
|
|
66
|
+
// Wrap for auto-hydration
|
|
67
|
+
return autoWrapClientComponent(Component, componentName) as T;
|
|
68
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart component export wrapper that automatically detects 'use client' directive
|
|
3
|
+
* and applies hydration wrapping without requiring manual withClientComponent() calls.
|
|
4
|
+
*
|
|
5
|
+
* This simulates Next.js's automatic client component handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import React from "react";
|
|
11
|
+
import { autoWrapClientComponent } from "./client-component-wrapper";
|
|
12
|
+
import { getComponentNameFromPath } from "./component-analyzer";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Cache to avoid re-reading files repeatedly
|
|
16
|
+
*/
|
|
17
|
+
const componentCache = new Map<
|
|
18
|
+
string,
|
|
19
|
+
{
|
|
20
|
+
isClientComponent: boolean;
|
|
21
|
+
wrappedComponent: React.ComponentType<any> | null;
|
|
22
|
+
}
|
|
23
|
+
>();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if a file has 'use client' directive by reading the source
|
|
27
|
+
*/
|
|
28
|
+
function detectClientComponent(sourceFilePath: string): {
|
|
29
|
+
isClientComponent: boolean;
|
|
30
|
+
componentPath: string;
|
|
31
|
+
} {
|
|
32
|
+
// Normalize the path
|
|
33
|
+
const normalizedPath = path.resolve(sourceFilePath);
|
|
34
|
+
|
|
35
|
+
if (componentCache.has(normalizedPath)) {
|
|
36
|
+
const cached = componentCache.get(normalizedPath)!;
|
|
37
|
+
return {
|
|
38
|
+
isClientComponent: cached.isClientComponent,
|
|
39
|
+
componentPath: normalizedPath,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let isClientComponent = false;
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const content = fs.readFileSync(normalizedPath, "utf-8");
|
|
47
|
+
isClientComponent = /^['"]use client['"];?\s*/.test(content);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// File doesn't exist or can't be read - treat as server component
|
|
50
|
+
isClientComponent = false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
componentCache.set(normalizedPath, {
|
|
54
|
+
isClientComponent,
|
|
55
|
+
wrappedComponent: null,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return { isClientComponent, componentPath: normalizedPath };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Higher-order component that automatically wraps client components.
|
|
63
|
+
*
|
|
64
|
+
* Usage in component files:
|
|
65
|
+
* ```tsx
|
|
66
|
+
* 'use client';
|
|
67
|
+
*
|
|
68
|
+
* function MyComponent() { ... }
|
|
69
|
+
* export default createClientComponent(MyComponent, __filename);
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* The __filename reference will be resolved at build time or runtime.
|
|
73
|
+
* This function detects the 'use client' directive and wraps accordingly.
|
|
74
|
+
*/
|
|
75
|
+
export function createClientComponent<T extends React.ComponentType<any>>(
|
|
76
|
+
Component: T,
|
|
77
|
+
sourceFile: string,
|
|
78
|
+
): T {
|
|
79
|
+
const { isClientComponent, componentPath } =
|
|
80
|
+
detectClientComponent(sourceFile);
|
|
81
|
+
|
|
82
|
+
if (!isClientComponent) {
|
|
83
|
+
// Not a client component, return as-is
|
|
84
|
+
return Component;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get the component name from the file path
|
|
88
|
+
const componentName = getComponentNameFromPath(componentPath);
|
|
89
|
+
|
|
90
|
+
// Wrap for automatic hydration
|
|
91
|
+
return autoWrapClientComponent(Component, componentName) as T;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Alternative: Automatically wrap a component based on its __filename
|
|
96
|
+
*
|
|
97
|
+
* Can be used as a default export wrapper:
|
|
98
|
+
* export default autoWrapIfClient(MyComponent, __filename);
|
|
99
|
+
*/
|
|
100
|
+
export function autoWrapIfClient<T extends React.ComponentType<any>>(
|
|
101
|
+
Component: T,
|
|
102
|
+
filename: string,
|
|
103
|
+
): T {
|
|
104
|
+
return createClientComponent(Component, filename);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Clear the component cache (useful for testing or hot reload)
|
|
109
|
+
*/
|
|
110
|
+
export function clearComponentCache(): void {
|
|
111
|
+
componentCache.clear();
|
|
112
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import {
|
|
4
|
+
getClientComponents,
|
|
5
|
+
getComponentNameFromPath,
|
|
6
|
+
} from "./component-analyzer";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generates separate entry point files for each client component
|
|
10
|
+
* These files will be bundled separately to reduce bundle size
|
|
11
|
+
*/
|
|
12
|
+
export function generateHydrationEntryFiles(
|
|
13
|
+
srcDir: string,
|
|
14
|
+
outputDir: string,
|
|
15
|
+
): string[] {
|
|
16
|
+
const clientComponentPaths = getClientComponents(srcDir);
|
|
17
|
+
|
|
18
|
+
// Ensure output directory exists
|
|
19
|
+
if (!fs.existsSync(outputDir)) {
|
|
20
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const entryFiles: string[] = [];
|
|
24
|
+
|
|
25
|
+
for (const componentPath of clientComponentPaths) {
|
|
26
|
+
const componentName = getComponentNameFromPath(componentPath);
|
|
27
|
+
const relativePath = path.relative(srcDir, componentPath);
|
|
28
|
+
|
|
29
|
+
// Compute relative import path from assets/hydrate to the component
|
|
30
|
+
const fromAssets = path.join(outputDir, "dummy.ts");
|
|
31
|
+
let importPath = path.relative(path.dirname(fromAssets), componentPath);
|
|
32
|
+
|
|
33
|
+
// Normalize path separators to forward slashes (required for imports)
|
|
34
|
+
importPath = importPath.replace(/\\/g, "/");
|
|
35
|
+
|
|
36
|
+
// Remove .tsx/.ts extension if present and add it explicitly
|
|
37
|
+
importPath = importPath.replace(/\.tsx?$/, "");
|
|
38
|
+
|
|
39
|
+
// Create entry file for this component
|
|
40
|
+
const entryFileName = `${componentName}.ts`;
|
|
41
|
+
const entryFilePath = path.join(outputDir, entryFileName);
|
|
42
|
+
|
|
43
|
+
// Generate the entry file content
|
|
44
|
+
const entryContent = `
|
|
45
|
+
import { hydrateRoot } from 'react-dom/client';
|
|
46
|
+
import * as React from 'react';
|
|
47
|
+
|
|
48
|
+
// Import the client component
|
|
49
|
+
import ${componentName} from '${importPath}';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Auto-generated hydration entry for ${componentName}
|
|
53
|
+
* This file is bundled separately to reduce the main bundle size
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
// Find all instances of this component marked with data-hydration-id
|
|
57
|
+
const instances = document.querySelectorAll('[data-hydration-id]');
|
|
58
|
+
|
|
59
|
+
instances.forEach((element) => {
|
|
60
|
+
const hydrationId = element.getAttribute('data-hydration-id');
|
|
61
|
+
if (hydrationId) {
|
|
62
|
+
try {
|
|
63
|
+
// Hydrate the component
|
|
64
|
+
hydrateRoot(element, React.createElement(${componentName}));
|
|
65
|
+
console.log('[Hydration] Successfully hydrated ${componentName} (id: ' + hydrationId + ')');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error('[Hydration] Failed to hydrate ${componentName}:', error);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
`.trim();
|
|
72
|
+
|
|
73
|
+
fs.writeFileSync(entryFilePath, entryContent, "utf-8");
|
|
74
|
+
entryFiles.push(entryFilePath);
|
|
75
|
+
|
|
76
|
+
console.log(`Generated hydration entry: ${entryFileName}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return entryFiles;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get all hydration entry files that were generated
|
|
84
|
+
*/
|
|
85
|
+
export function getHydrationEntryFiles(outputDir: string): string[] {
|
|
86
|
+
if (!fs.existsSync(outputDir)) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return fs
|
|
91
|
+
.readdirSync(outputDir)
|
|
92
|
+
.filter((file) => file.endsWith(".ts") && !file.endsWith(".spec.ts"))
|
|
93
|
+
.map((file) => path.join(outputDir, file));
|
|
94
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydration manifest loader
|
|
3
|
+
* Reads the generated hydration manifest at runtime to map component names to chunked filenames
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as fs from "fs";
|
|
7
|
+
import * as path from "path";
|
|
8
|
+
|
|
9
|
+
export interface HydrationManifest {
|
|
10
|
+
[componentName: string]: string; // componentName -> chunkFileName
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let cachedManifest: HydrationManifest | null = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get the hydration manifest
|
|
17
|
+
* The manifest is generated during build and contains mappings of component names to cache-busted chunk filenames
|
|
18
|
+
*/
|
|
19
|
+
export function getHydrationManifest(): HydrationManifest {
|
|
20
|
+
if (cachedManifest) {
|
|
21
|
+
return cachedManifest;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Try dist folder first (where it's generated), then legacy src location
|
|
25
|
+
const manifestPaths = [
|
|
26
|
+
path.join(process.cwd(), "dist", "hydration-manifest.json"),
|
|
27
|
+
path.join(process.cwd(), "src", "hydration-manifest.json"), // legacy fallback
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
let manifestPath: string | null = null;
|
|
31
|
+
for (const p of manifestPaths) {
|
|
32
|
+
if (fs.existsSync(p)) {
|
|
33
|
+
manifestPath = p;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!manifestPath) {
|
|
39
|
+
console.warn(
|
|
40
|
+
"[Hydration] Manifest file not found at any of:",
|
|
41
|
+
manifestPaths,
|
|
42
|
+
"- ensure build:hydration has been run",
|
|
43
|
+
);
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const content = fs.readFileSync(manifestPath, "utf-8");
|
|
49
|
+
cachedManifest = JSON.parse(content) as HydrationManifest;
|
|
50
|
+
return cachedManifest;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error("[Hydration] Failed to load hydration manifest:", error);
|
|
53
|
+
cachedManifest = {};
|
|
54
|
+
return cachedManifest;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get the chunk filename for a specific component
|
|
60
|
+
*/
|
|
61
|
+
export function getChunkFileName(componentName: string): string | null {
|
|
62
|
+
const manifest = getHydrationManifest();
|
|
63
|
+
return manifest[componentName] || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the public path for a component chunk
|
|
68
|
+
*/
|
|
69
|
+
export function getChunkPath(componentName: string): string | null {
|
|
70
|
+
const fileName = getChunkFileName(componentName);
|
|
71
|
+
return fileName ? `/chunks/${fileName}` : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Invalidate cached manifest (useful for development mode with hot reload)
|
|
76
|
+
*/
|
|
77
|
+
export function invalidateManifestCache(): void {
|
|
78
|
+
cachedManifest = null;
|
|
79
|
+
}
|