@harpy-js/core 0.4.7

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