@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,70 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
2
|
+
import * as crypto from "crypto";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tracks client components during SSR rendering
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface ClientComponentInstance {
|
|
9
|
+
componentPath: string;
|
|
10
|
+
componentName: string;
|
|
11
|
+
instanceId: string;
|
|
12
|
+
props: Record<string, any>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface HydrationContext {
|
|
16
|
+
clientComponents: Map<string, ClientComponentInstance>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Global context storage for tracking client components during SSR
|
|
20
|
+
export const hydrationContext = new AsyncLocalStorage<HydrationContext>();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate a unique instance ID for a component
|
|
24
|
+
*/
|
|
25
|
+
export function generateInstanceId(componentPath: string): string {
|
|
26
|
+
return `${crypto.randomBytes(4).toString("hex")}-${Date.now()}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Initialize hydration context for a request
|
|
31
|
+
*/
|
|
32
|
+
export function initializeHydrationContext(): HydrationContext {
|
|
33
|
+
const context: HydrationContext = {
|
|
34
|
+
clientComponents: new Map(),
|
|
35
|
+
};
|
|
36
|
+
return context;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Register a client component instance during SSR
|
|
41
|
+
*/
|
|
42
|
+
export function registerClientComponent(
|
|
43
|
+
instance: ClientComponentInstance,
|
|
44
|
+
): void {
|
|
45
|
+
const context = hydrationContext.getStore();
|
|
46
|
+
if (context) {
|
|
47
|
+
context.clientComponents.set(instance.instanceId, instance);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get all registered client components
|
|
53
|
+
*/
|
|
54
|
+
export function getClientComponents(): ClientComponentInstance[] {
|
|
55
|
+
const context = hydrationContext.getStore();
|
|
56
|
+
if (!context) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return Array.from(context.clientComponents.values());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Clear hydration context
|
|
64
|
+
*/
|
|
65
|
+
export function clearHydrationContext(): void {
|
|
66
|
+
const context = hydrationContext.getStore();
|
|
67
|
+
if (context) {
|
|
68
|
+
context.clientComponents.clear();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { NestFastifyApplication } from "@nestjs/platform-fastify";
|
|
2
|
+
import { FastifyReply } from "fastify";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { renderToPipeableStream, renderToString } from "react-dom/server";
|
|
5
|
+
import { MetaOptions, RenderOptions } from "../decorators/jsx.decorator";
|
|
6
|
+
import { hydrationContext, initializeHydrationContext } from "./hydration";
|
|
7
|
+
import { getChunkPath, getHydrationManifest } from "./hydration-manifest";
|
|
8
|
+
import { LiveReloadController } from "./live-reload.controller";
|
|
9
|
+
import { StaticAssetsController } from "./static-assets.controller";
|
|
10
|
+
|
|
11
|
+
export interface JsxLayoutProps {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
meta?: MetaOptions;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type JsxLayout = (props: JsxLayoutProps) => React.ReactElement;
|
|
17
|
+
|
|
18
|
+
// Cache for component-to-chunk path mappings (loaded once at startup)
|
|
19
|
+
const chunkPathCache = new Map<string, string>();
|
|
20
|
+
|
|
21
|
+
// Preload hydration manifest and cache chunk paths
|
|
22
|
+
function initializeChunkCache() {
|
|
23
|
+
const manifest = getHydrationManifest();
|
|
24
|
+
Object.keys(manifest).forEach((componentName) => {
|
|
25
|
+
const path = getChunkPath(componentName);
|
|
26
|
+
if (path) {
|
|
27
|
+
chunkPathCache.set(componentName, path);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
console.log(
|
|
31
|
+
`[JSX Engine] Preloaded ${chunkPathCache.size} component chunk mappings`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function withJsxEngine(
|
|
36
|
+
app: NestFastifyApplication,
|
|
37
|
+
defaultLayout: JsxLayout,
|
|
38
|
+
) {
|
|
39
|
+
const isDev = process.env.NODE_ENV !== "production";
|
|
40
|
+
|
|
41
|
+
// Initialize chunk cache at startup (O(1) lookups for all requests)
|
|
42
|
+
initializeChunkCache();
|
|
43
|
+
|
|
44
|
+
// Register live reload controllers in development mode
|
|
45
|
+
if (isDev) {
|
|
46
|
+
const httpAdapter = app.getHttpAdapter();
|
|
47
|
+
const liveReloadController = new LiveReloadController();
|
|
48
|
+
const staticAssetsController = new StaticAssetsController();
|
|
49
|
+
|
|
50
|
+
// Register routes manually
|
|
51
|
+
httpAdapter.get("/__harpy/live-reload", (req: any, reply: any) => {
|
|
52
|
+
liveReloadController.liveReload(reply);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
httpAdapter.post("/__harpy/live-reload/trigger", (req: any, reply: any) => {
|
|
56
|
+
liveReloadController.notifyReload();
|
|
57
|
+
reply.send({ status: "ok" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
httpAdapter.get("/__harpy/live-reload.js", (req: any, reply: any) => {
|
|
61
|
+
staticAssetsController.liveReloadScript(reply);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Override the render method to use the jsx engine
|
|
66
|
+
// @ts-expect-error Monkey patch to make render method use jsx
|
|
67
|
+
app.getHttpAdapter().render = async function (
|
|
68
|
+
reply: FastifyReply,
|
|
69
|
+
view: [any, RenderOptions],
|
|
70
|
+
options,
|
|
71
|
+
) {
|
|
72
|
+
const res = reply.raw;
|
|
73
|
+
|
|
74
|
+
// Redirected, bad request or error, there is no need to render the view
|
|
75
|
+
if (reply.statusCode >= 300) {
|
|
76
|
+
res.end();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [component, controllerOpts] = view;
|
|
81
|
+
const layout = controllerOpts.layout ?? defaultLayout;
|
|
82
|
+
|
|
83
|
+
// Prepare options for the component
|
|
84
|
+
const props = {
|
|
85
|
+
...options,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
let meta: MetaOptions | undefined = undefined;
|
|
89
|
+
if (typeof controllerOpts.meta === "function") {
|
|
90
|
+
try {
|
|
91
|
+
meta = await controllerOpts.meta(reply.request, props);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error("Error resolving dynamic meta:", e);
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
meta = controllerOpts.meta;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Inject meta into layout props
|
|
100
|
+
const layoutProps = {
|
|
101
|
+
...props,
|
|
102
|
+
meta,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let html: React.ReactElement;
|
|
106
|
+
if (layout) {
|
|
107
|
+
layoutProps.children = React.createElement(component, props);
|
|
108
|
+
html = React.createElement(layout, layoutProps);
|
|
109
|
+
} else {
|
|
110
|
+
html = React.createElement(component, props);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Initialize hydration context for this request
|
|
114
|
+
const hydrationCtx = initializeHydrationContext();
|
|
115
|
+
|
|
116
|
+
// Set up component registry for client component wrapping
|
|
117
|
+
global.__COMPONENT_REGISTRY__ = (data) => {
|
|
118
|
+
hydrationCtx.clientComponents.set(data.instanceId, data);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Single pass: render to string to collect which components are used
|
|
122
|
+
// This renders the component tree and populates hydrationCtx with registered components
|
|
123
|
+
const startTime = Date.now();
|
|
124
|
+
let htmlString = "";
|
|
125
|
+
|
|
126
|
+
hydrationContext.run(hydrationCtx, () => {
|
|
127
|
+
try {
|
|
128
|
+
htmlString = renderToString(html);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error(
|
|
131
|
+
"[JSX Engine] Render error:",
|
|
132
|
+
(e as Error).message?.split("\n")[0],
|
|
133
|
+
);
|
|
134
|
+
throw e;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Extract registered components from the context
|
|
139
|
+
const registeredComponents = Array.from(
|
|
140
|
+
hydrationCtx.clientComponents.values(),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const uniqueComponentNames = new Set(
|
|
144
|
+
registeredComponents.map((c) => c.componentName),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
// Use cached chunk paths (O(1) lookups)
|
|
148
|
+
const hydrationScripts = Array.from(uniqueComponentNames)
|
|
149
|
+
.map((componentName) => {
|
|
150
|
+
const path = chunkPathCache.get(componentName);
|
|
151
|
+
if (!path) {
|
|
152
|
+
// Fallback to live lookup if not in cache (shouldn't happen in production)
|
|
153
|
+
const livePath = getChunkPath(componentName);
|
|
154
|
+
if (livePath) {
|
|
155
|
+
chunkPathCache.set(componentName, livePath);
|
|
156
|
+
return { componentName, path: livePath };
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return { componentName, path };
|
|
161
|
+
})
|
|
162
|
+
.filter((script) => script !== null) as Array<{
|
|
163
|
+
componentName: string;
|
|
164
|
+
path: string;
|
|
165
|
+
}>;
|
|
166
|
+
|
|
167
|
+
const renderTime = Date.now() - startTime;
|
|
168
|
+
if (isDev) {
|
|
169
|
+
console.log(
|
|
170
|
+
`[JSX Engine] Rendered in ${renderTime}ms with ${hydrationScripts.length} scripts for:`,
|
|
171
|
+
Array.from(uniqueComponentNames).join(", "),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Build hydration scripts HTML (vendor bundle + component chunks)
|
|
176
|
+
let hydrationScriptsHtml = "";
|
|
177
|
+
if (hydrationScripts.length > 0) {
|
|
178
|
+
// Always load vendor bundle first (contains React + ReactDOM)
|
|
179
|
+
hydrationScriptsHtml = '<script src="/chunks/vendor.js"></script>';
|
|
180
|
+
// Then load component-specific chunks
|
|
181
|
+
hydrationScripts.forEach((script) => {
|
|
182
|
+
hydrationScriptsHtml += `<script src="${script.path}"></script>`;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Inject scripts before closing body tag
|
|
187
|
+
if (isDev) {
|
|
188
|
+
// In development, add live reload script
|
|
189
|
+
const liveReloadScript =
|
|
190
|
+
'<script src="/__harpy/live-reload.js"></script>';
|
|
191
|
+
const scriptsToInject = `${hydrationScriptsHtml}${liveReloadScript}`;
|
|
192
|
+
htmlString = htmlString.replace("</body>", `${scriptsToInject}</body>`);
|
|
193
|
+
} else if (hydrationScriptsHtml) {
|
|
194
|
+
// In production, only inject hydration scripts
|
|
195
|
+
htmlString = htmlString.replace(
|
|
196
|
+
"</body>",
|
|
197
|
+
`${hydrationScriptsHtml}</body>`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
res.setHeader("content-type", "text/html");
|
|
202
|
+
reply.status(reply.statusCode || 200);
|
|
203
|
+
res.end(htmlString);
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Harpy Live Reload Client
|
|
3
|
+
* Connects to the dev server via SSE and reloads the page when changes are detected
|
|
4
|
+
*/
|
|
5
|
+
(function () {
|
|
6
|
+
if (typeof window === "undefined") return;
|
|
7
|
+
|
|
8
|
+
const eventSource = new EventSource("/__harpy/live-reload");
|
|
9
|
+
|
|
10
|
+
eventSource.onmessage = (event) => {
|
|
11
|
+
try {
|
|
12
|
+
const data = JSON.parse(event.data);
|
|
13
|
+
if (data.type === "reload") {
|
|
14
|
+
console.log("[Harpy] Reloading page...");
|
|
15
|
+
window.location.reload();
|
|
16
|
+
} else if (data.type === "connected") {
|
|
17
|
+
console.log("[Harpy] Live reload connected");
|
|
18
|
+
}
|
|
19
|
+
} catch (err) {
|
|
20
|
+
console.error("[Harpy] Failed to parse message:", err);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
eventSource.onerror = () => {
|
|
25
|
+
console.log("[Harpy] Live reload disconnected, retrying...");
|
|
26
|
+
eventSource.close();
|
|
27
|
+
// Retry connection after 1 second
|
|
28
|
+
setTimeout(() => {
|
|
29
|
+
window.location.reload();
|
|
30
|
+
}, 1000);
|
|
31
|
+
};
|
|
32
|
+
})();
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { FastifyReply } from "fastify";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Live Reload Controller - Provides SSE endpoint for development hot-reload
|
|
5
|
+
* Only active in development mode
|
|
6
|
+
*/
|
|
7
|
+
export class LiveReloadController {
|
|
8
|
+
private clients: FastifyReply[] = [];
|
|
9
|
+
private lastReloadTime = Date.now();
|
|
10
|
+
|
|
11
|
+
liveReload(reply: FastifyReply): void {
|
|
12
|
+
// Set headers for SSE
|
|
13
|
+
reply.raw.writeHead(200, {
|
|
14
|
+
"Content-Type": "text/event-stream",
|
|
15
|
+
"Cache-Control": "no-cache",
|
|
16
|
+
Connection: "keep-alive",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Add client to list
|
|
20
|
+
this.clients.push(reply);
|
|
21
|
+
|
|
22
|
+
// Send initial connection message
|
|
23
|
+
reply.raw.write(`data: ${JSON.stringify({ type: "connected" })}\n\n`);
|
|
24
|
+
|
|
25
|
+
// Remove client on close
|
|
26
|
+
reply.raw.on("close", () => {
|
|
27
|
+
const index = this.clients.indexOf(reply);
|
|
28
|
+
if (index !== -1) {
|
|
29
|
+
this.clients.splice(index, 1);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Notify all connected clients to reload
|
|
36
|
+
* This should be called when assets are rebuilt
|
|
37
|
+
*/
|
|
38
|
+
triggerReload(): { success: boolean } {
|
|
39
|
+
this.notifyReload();
|
|
40
|
+
return { success: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public notifyReload() {
|
|
44
|
+
this.lastReloadTime = Date.now();
|
|
45
|
+
const message = `data: ${JSON.stringify({ type: "reload", timestamp: this.lastReloadTime })}\n\n`;
|
|
46
|
+
|
|
47
|
+
this.clients.forEach((client) => {
|
|
48
|
+
try {
|
|
49
|
+
client.raw.write(message);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
// Client disconnected
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { Injectable } from "@nestjs/common";
|
|
2
|
+
import type {
|
|
3
|
+
NavItem,
|
|
4
|
+
NavSection,
|
|
5
|
+
NavigationRegistry,
|
|
6
|
+
} from "./types/nav.types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared navigation service for registering documentation sections and items.
|
|
10
|
+
* This service is intended to be provided by the core RouterModule so feature
|
|
11
|
+
* modules can register their routes during module initialization.
|
|
12
|
+
*/
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class NavigationService implements NavigationRegistry {
|
|
15
|
+
private sections: Map<string, NavSection> = new Map();
|
|
16
|
+
// Items registered without a section are kept here and surfaced as an
|
|
17
|
+
// implicit, top-level section by `getAllSections()`.
|
|
18
|
+
private topLevelItems: NavItem[] = [];
|
|
19
|
+
// Cached, sorted snapshot of sections (shallow copies). Rebuilt when
|
|
20
|
+
// registrations change. Use `dirty` to mark the cache stale.
|
|
21
|
+
private cachedSections: NavSection[] | null = null;
|
|
22
|
+
private dirty = true;
|
|
23
|
+
// Map of normalized href -> array of { sectionId?, itemId }
|
|
24
|
+
private hrefIndex: Map<
|
|
25
|
+
string,
|
|
26
|
+
Array<{ sectionId?: string; itemId: string }>
|
|
27
|
+
> = new Map();
|
|
28
|
+
|
|
29
|
+
constructor() {}
|
|
30
|
+
|
|
31
|
+
registerSection(section: NavSection): void {
|
|
32
|
+
this.sections.set(section.id, section);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
addItemToSection(sectionId: string, item: NavItem): void {
|
|
36
|
+
let section = this.sections.get(sectionId);
|
|
37
|
+
if (!section) {
|
|
38
|
+
// Lazily create the section if it doesn't exist. This keeps the core
|
|
39
|
+
// package minimal by default while allowing feature modules to add
|
|
40
|
+
// routes without needing to pre-register sections.
|
|
41
|
+
const humanize = (id: string) =>
|
|
42
|
+
id.replace(/[-_/]+/g, " ").replace(/(^|\s)\S/g, (s) => s.toUpperCase());
|
|
43
|
+
|
|
44
|
+
section = {
|
|
45
|
+
id: sectionId,
|
|
46
|
+
title: humanize(sectionId),
|
|
47
|
+
items: [],
|
|
48
|
+
// preserve undefined order by default
|
|
49
|
+
};
|
|
50
|
+
this.registerSection(section);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
section.items.push(item);
|
|
54
|
+
// preserve raw insertion order in the array; sorting is applied when
|
|
55
|
+
// callers request `getAllSections()` so we can compute ordering on demand.
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
registerItem(item: NavItem): void {
|
|
59
|
+
this.topLevelItems.push(item);
|
|
60
|
+
this.dirty = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getAllSections(): NavSection[] {
|
|
64
|
+
// Return cached snapshot when available to avoid repeated sorting and
|
|
65
|
+
// object allocations. Rebuild only when registrations changed.
|
|
66
|
+
if (!this.dirty && this.cachedSections) {
|
|
67
|
+
return this.cachedSections.map((s) => ({ ...s, items: s.items.slice() }));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Build an ordered list of sections. If there are any top-level items
|
|
71
|
+
// (items registered without a section) surface them as an implicit
|
|
72
|
+
// top-level section. That implicit section is placed before other
|
|
73
|
+
// sections by using a very low ordering value so unsectioned items
|
|
74
|
+
// appear first by default.
|
|
75
|
+
const sectionsList: NavSection[] = Array.from(this.sections.values());
|
|
76
|
+
if (this.topLevelItems.length > 0) {
|
|
77
|
+
sectionsList.unshift({
|
|
78
|
+
id: "__top__",
|
|
79
|
+
title: "",
|
|
80
|
+
items: this.topLevelItems.slice(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const arr = sectionsList.map((s, idx) => ({
|
|
85
|
+
section: s,
|
|
86
|
+
idx,
|
|
87
|
+
order:
|
|
88
|
+
s.id === "__top__"
|
|
89
|
+
? Number.NEGATIVE_INFINITY
|
|
90
|
+
: typeof s.order === "number"
|
|
91
|
+
? s.order
|
|
92
|
+
: Number.POSITIVE_INFINITY,
|
|
93
|
+
}));
|
|
94
|
+
|
|
95
|
+
arr.sort((a, b) => {
|
|
96
|
+
if (a.order === b.order) return a.idx - b.idx;
|
|
97
|
+
return a.order - b.order;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// For each section, return a copy where the items are sorted by their
|
|
101
|
+
// optional `order` (lowest first) and then by insertion index.
|
|
102
|
+
const built = arr.map((x) => {
|
|
103
|
+
const s = x.section;
|
|
104
|
+
|
|
105
|
+
const itemsWithMeta = s.items.map((it, i) => ({
|
|
106
|
+
item: it,
|
|
107
|
+
idx: i,
|
|
108
|
+
order:
|
|
109
|
+
typeof it.order === "number" ? it.order : Number.POSITIVE_INFINITY,
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
itemsWithMeta.sort((u, v) => {
|
|
113
|
+
if (u.order === v.order) return u.idx - v.idx;
|
|
114
|
+
return u.order - v.order;
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
id: s.id,
|
|
119
|
+
title: s.title,
|
|
120
|
+
order: s.order,
|
|
121
|
+
items: itemsWithMeta.map((m) => m.item),
|
|
122
|
+
} as NavSection;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Rebuild href index for fast active lookup.
|
|
126
|
+
this.hrefIndex.clear();
|
|
127
|
+
const normalize = (p?: string) => {
|
|
128
|
+
if (!p) return "";
|
|
129
|
+
const withoutQuery = p.split(/[?#]/)[0];
|
|
130
|
+
if (withoutQuery.length > 1 && withoutQuery.endsWith("/"))
|
|
131
|
+
return withoutQuery.slice(0, -1);
|
|
132
|
+
return withoutQuery;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
for (const s of built) {
|
|
136
|
+
for (const it of s.items) {
|
|
137
|
+
if (!it.href) continue;
|
|
138
|
+
const key = normalize(it.href);
|
|
139
|
+
if (!this.hrefIndex.has(key)) this.hrefIndex.set(key, []);
|
|
140
|
+
this.hrefIndex
|
|
141
|
+
.get(key)!
|
|
142
|
+
.push({
|
|
143
|
+
sectionId: s.id === "__top__" ? undefined : s.id,
|
|
144
|
+
itemId: it.id,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
this.cachedSections = built;
|
|
150
|
+
this.dirty = false;
|
|
151
|
+
// Return shallow clones so callers cannot mutate the internal cache.
|
|
152
|
+
return built.map((s) => ({ ...s, items: s.items.slice() }));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private ensureCache(): void {
|
|
156
|
+
if (this.dirty) this.getAllSections();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Fast active-item resolution using the prebuilt `hrefIndex`. This
|
|
161
|
+
* performs ancestor matching by trimming path segments and checking the
|
|
162
|
+
* index for the longest matching prefix. Returns the first registered
|
|
163
|
+
* item for a matched href.
|
|
164
|
+
*/
|
|
165
|
+
getActiveItemId(currentPath?: string): string | undefined {
|
|
166
|
+
if (!currentPath) return undefined;
|
|
167
|
+
this.ensureCache();
|
|
168
|
+
const normalize = (p?: string) => {
|
|
169
|
+
if (!p) return "";
|
|
170
|
+
const withoutQuery = p.split(/[?#]/)[0];
|
|
171
|
+
if (withoutQuery.length > 1 && withoutQuery.endsWith("/"))
|
|
172
|
+
return withoutQuery.slice(0, -1);
|
|
173
|
+
return withoutQuery;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let cur = normalize(currentPath);
|
|
177
|
+
while (cur !== "") {
|
|
178
|
+
const entry = this.hrefIndex.get(cur);
|
|
179
|
+
if (entry && entry.length > 0) return entry[0].itemId;
|
|
180
|
+
const lastSlash = cur.lastIndexOf("/");
|
|
181
|
+
if (lastSlash === -1) break;
|
|
182
|
+
if (lastSlash === 0) {
|
|
183
|
+
cur = "/";
|
|
184
|
+
} else {
|
|
185
|
+
cur = cur.slice(0, lastSlash);
|
|
186
|
+
}
|
|
187
|
+
if (cur === "/") {
|
|
188
|
+
const entryRoot = this.hrefIndex.get("/");
|
|
189
|
+
if (entryRoot && entryRoot.length > 0) return entryRoot[0].itemId;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Return sections where each item's `active` flag is computed against
|
|
199
|
+
* `currentPath`. This does not mutate the registered items — it returns
|
|
200
|
+
* shallow copies suitable for rendering.
|
|
201
|
+
*/
|
|
202
|
+
getSectionsForRoute(currentPath?: string): NavSection[] {
|
|
203
|
+
const normalize = (p?: string) => {
|
|
204
|
+
if (!p) return "";
|
|
205
|
+
const withoutQuery = p.split(/[?#]/)[0];
|
|
206
|
+
// strip trailing slash except for root
|
|
207
|
+
if (withoutQuery.length > 1 && withoutQuery.endsWith("/")) {
|
|
208
|
+
return withoutQuery.slice(0, -1);
|
|
209
|
+
}
|
|
210
|
+
return withoutQuery;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const matches = (itemHref: string | undefined, cur: string | undefined) => {
|
|
214
|
+
if (!itemHref || !cur) return false;
|
|
215
|
+
const a = normalize(itemHref);
|
|
216
|
+
const b = normalize(cur);
|
|
217
|
+
if (!a) return false;
|
|
218
|
+
if (a === b) return true;
|
|
219
|
+
// treat an item as active when the current path is a descendant of the
|
|
220
|
+
// item's href (e.g. `/docs` matches `/docs/getting-started`).
|
|
221
|
+
return b.startsWith(a + "/");
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const base = this.getAllSections();
|
|
225
|
+
if (!currentPath) return base;
|
|
226
|
+
|
|
227
|
+
// Fast path: find the single active item id and mark only that item.
|
|
228
|
+
const activeId = this.getActiveItemId(currentPath);
|
|
229
|
+
if (!activeId) return base;
|
|
230
|
+
|
|
231
|
+
return base.map((s) => ({
|
|
232
|
+
...s,
|
|
233
|
+
items: s.items.map((it) => ({ ...it, active: it.id === activeId })),
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
getSection(sectionId: string): NavSection | undefined {
|
|
238
|
+
return this.sections.get(sectionId);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Move an already-registered section to the front of the navigation.
|
|
243
|
+
* Useful when ordering must be adjusted after other modules have registered.
|
|
244
|
+
*/
|
|
245
|
+
moveSectionToFront(sectionId: string): void {
|
|
246
|
+
const sec = this.sections.get(sectionId);
|
|
247
|
+
if (!sec) return;
|
|
248
|
+
|
|
249
|
+
const newMap = new Map<string, NavSection>();
|
|
250
|
+
newMap.set(sectionId, sec);
|
|
251
|
+
for (const [k, v] of this.sections.entries()) {
|
|
252
|
+
if (k === sectionId) continue;
|
|
253
|
+
newMap.set(k, v);
|
|
254
|
+
}
|
|
255
|
+
this.sections = newMap;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { FastifyReply } from "fastify";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Static Assets Controller - Serves framework assets like live-reload client
|
|
7
|
+
*/
|
|
8
|
+
export class StaticAssetsController {
|
|
9
|
+
liveReloadScript(reply: FastifyReply): void {
|
|
10
|
+
const scriptPath = path.join(__dirname, "live-reload-client.js");
|
|
11
|
+
|
|
12
|
+
if (fs.existsSync(scriptPath)) {
|
|
13
|
+
const script = fs.readFileSync(scriptPath, "utf-8");
|
|
14
|
+
reply.type("application/javascript").send(script);
|
|
15
|
+
} else {
|
|
16
|
+
reply.code(404).send("Live reload script not found");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export interface NavItem {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
href?: string;
|
|
5
|
+
/**
|
|
6
|
+
* Runtime-only hint indicating whether this item is currently active.
|
|
7
|
+
* Consumers may inspect this flag when rendering navigation UI. The
|
|
8
|
+
* navigation service exposes helpers to compute active state from the
|
|
9
|
+
* current route instead of mutating the registered items directly.
|
|
10
|
+
*/
|
|
11
|
+
active?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Optional numeric priority within a section. Lower numbers appear earlier.
|
|
14
|
+
* If omitted, registration order is used as a tiebreaker.
|
|
15
|
+
*/
|
|
16
|
+
order?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NavSection {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
items: NavItem[];
|
|
23
|
+
/**
|
|
24
|
+
* Optional numeric order. Lower numbers appear earlier in navigation.
|
|
25
|
+
* If omitted, insertion order is used as a tiebreaker.
|
|
26
|
+
*/
|
|
27
|
+
order?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Minimal interface describing the navigation service surface used by feature modules.
|
|
31
|
+
export interface NavigationRegistry {
|
|
32
|
+
registerSection(section: NavSection): void;
|
|
33
|
+
addItemToSection(sectionId: string, item: NavItem): void;
|
|
34
|
+
/**
|
|
35
|
+
* Register a navigation item that does not belong to any section.
|
|
36
|
+
* These items will be surfaced in a top-level, implicit section in
|
|
37
|
+
* the results returned by `getAllSections()`.
|
|
38
|
+
*/
|
|
39
|
+
registerItem(item: NavItem): void;
|
|
40
|
+
/**
|
|
41
|
+
* Get all sections with items marked according to the provided route.
|
|
42
|
+
* If `currentPath` is omitted, this behaves the same as `getAllSections()`.
|
|
43
|
+
*/
|
|
44
|
+
getSectionsForRoute(currentPath?: string): NavSection[];
|
|
45
|
+
/**
|
|
46
|
+
* Fast lookup for the active item's id for a given route. This is intended
|
|
47
|
+
* to be an inexpensive alternative to returning full sections with active
|
|
48
|
+
* flags when clients prefer to compute or sync active state themselves.
|
|
49
|
+
*/
|
|
50
|
+
getActiveItemId(currentPath?: string): string | undefined;
|
|
51
|
+
getAllSections(): NavSection[];
|
|
52
|
+
getSection(sectionId: string): NavSection | undefined;
|
|
53
|
+
}
|