@useavalon/avalon 0.1.11 → 0.1.13

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 (141) hide show
  1. package/README.md +54 -54
  2. package/mod.ts +302 -302
  3. package/package.json +49 -26
  4. package/src/build/integration-bundler-plugin.ts +116 -116
  5. package/src/build/integration-config.ts +168 -168
  6. package/src/build/integration-detection-plugin.ts +117 -117
  7. package/src/build/integration-resolver-plugin.ts +90 -90
  8. package/src/build/island-manifest.ts +269 -269
  9. package/src/build/island-types-generator.ts +476 -476
  10. package/src/build/mdx-island-transform.ts +464 -464
  11. package/src/build/mdx-plugin.ts +98 -98
  12. package/src/build/page-island-transform.ts +598 -598
  13. package/src/build/prop-extractors/index.ts +21 -21
  14. package/src/build/prop-extractors/lit.ts +140 -140
  15. package/src/build/prop-extractors/qwik.ts +16 -16
  16. package/src/build/prop-extractors/solid.ts +125 -125
  17. package/src/build/prop-extractors/svelte.ts +194 -194
  18. package/src/build/prop-extractors/vue.ts +111 -111
  19. package/src/build/sidecar-file-manager.ts +104 -104
  20. package/src/build/sidecar-renderer.ts +30 -30
  21. package/src/client/adapters/index.ts +21 -13
  22. package/src/client/components.ts +35 -35
  23. package/src/client/css-hmr-handler.ts +344 -344
  24. package/src/client/framework-adapter.ts +462 -462
  25. package/src/client/hmr-coordinator.ts +396 -396
  26. package/src/client/hmr-error-overlay.js +533 -533
  27. package/src/client/main.js +824 -816
  28. package/src/client/types/framework-runtime.d.ts +68 -68
  29. package/src/client/types/vite-hmr.d.ts +46 -46
  30. package/src/client/types/vite-virtual-modules.d.ts +70 -60
  31. package/src/components/Image.tsx +123 -123
  32. package/src/components/IslandErrorBoundary.tsx +145 -145
  33. package/src/components/LayoutDataErrorBoundary.tsx +141 -141
  34. package/src/components/LayoutErrorBoundary.tsx +127 -127
  35. package/src/components/PersistentIsland.tsx +52 -52
  36. package/src/components/StreamingErrorBoundary.tsx +233 -233
  37. package/src/components/StreamingLayout.tsx +538 -538
  38. package/src/core/components/component-analyzer.ts +192 -192
  39. package/src/core/components/component-detection.ts +508 -508
  40. package/src/core/components/enhanced-framework-detector.ts +500 -500
  41. package/src/core/components/framework-registry.ts +563 -563
  42. package/src/core/content/mdx-processor.ts +46 -46
  43. package/src/core/integrations/index.ts +19 -19
  44. package/src/core/integrations/loader.ts +125 -125
  45. package/src/core/integrations/registry.ts +175 -175
  46. package/src/core/islands/island-persistence.ts +325 -325
  47. package/src/core/islands/island-state-serializer.ts +258 -258
  48. package/src/core/islands/persistent-island-context.tsx +80 -80
  49. package/src/core/islands/use-persistent-state.ts +68 -68
  50. package/src/core/layout/enhanced-layout-resolver.ts +322 -322
  51. package/src/core/layout/layout-cache-manager.ts +485 -485
  52. package/src/core/layout/layout-composer.ts +357 -357
  53. package/src/core/layout/layout-data-loader.ts +516 -516
  54. package/src/core/layout/layout-discovery.ts +243 -243
  55. package/src/core/layout/layout-matcher.ts +299 -299
  56. package/src/core/layout/layout-types.ts +110 -110
  57. package/src/core/modules/framework-module-resolver.ts +273 -273
  58. package/src/islands/component-analysis.ts +213 -213
  59. package/src/islands/css-utils.ts +565 -565
  60. package/src/islands/discovery/index.ts +80 -80
  61. package/src/islands/discovery/registry.ts +340 -340
  62. package/src/islands/discovery/resolver.ts +477 -477
  63. package/src/islands/discovery/scanner.ts +386 -386
  64. package/src/islands/discovery/types.ts +117 -117
  65. package/src/islands/discovery/validator.ts +544 -544
  66. package/src/islands/discovery/watcher.ts +368 -368
  67. package/src/islands/framework-detection.ts +428 -428
  68. package/src/islands/integration-loader.ts +490 -490
  69. package/src/islands/island.tsx +565 -565
  70. package/src/islands/render-cache.ts +550 -550
  71. package/src/islands/types.ts +80 -80
  72. package/src/islands/universal-css-collector.ts +157 -157
  73. package/src/islands/universal-head-collector.ts +137 -137
  74. package/src/layout-system.d.ts +592 -592
  75. package/src/layout-system.ts +218 -218
  76. package/src/middleware/discovery.ts +268 -268
  77. package/src/middleware/executor.ts +315 -315
  78. package/src/middleware/index.ts +76 -76
  79. package/src/middleware/types.ts +99 -99
  80. package/src/nitro/build-config.ts +575 -575
  81. package/src/nitro/config.ts +483 -483
  82. package/src/nitro/error-handler.ts +636 -636
  83. package/src/nitro/index.ts +173 -173
  84. package/src/nitro/island-manifest.ts +584 -584
  85. package/src/nitro/middleware-adapter.ts +260 -260
  86. package/src/nitro/renderer.ts +1471 -1471
  87. package/src/nitro/route-discovery.ts +439 -439
  88. package/src/nitro/types.ts +321 -321
  89. package/src/render/collect-css.ts +198 -198
  90. package/src/render/error-pages.ts +79 -79
  91. package/src/render/isolated-ssr-renderer.ts +654 -654
  92. package/src/render/ssr.ts +1030 -1030
  93. package/src/schemas/api.ts +30 -30
  94. package/src/schemas/core.ts +64 -64
  95. package/src/schemas/index.ts +212 -212
  96. package/src/schemas/layout.ts +279 -279
  97. package/src/schemas/routing/index.ts +38 -38
  98. package/src/schemas/routing.ts +376 -376
  99. package/src/types/as-island.ts +20 -20
  100. package/src/types/image.d.ts +106 -106
  101. package/src/types/index.d.ts +22 -22
  102. package/src/types/island-jsx.d.ts +33 -33
  103. package/src/types/island-prop.d.ts +20 -20
  104. package/src/types/layout.ts +285 -285
  105. package/src/types/mdx.d.ts +6 -6
  106. package/src/types/routing.ts +555 -555
  107. package/src/types/types.ts +5 -5
  108. package/src/types/urlpattern.d.ts +49 -49
  109. package/src/types/vite-env.d.ts +11 -11
  110. package/src/utils/dev-logger.ts +299 -299
  111. package/src/utils/fs.ts +151 -151
  112. package/src/vite-plugin/auto-discover.ts +551 -551
  113. package/src/vite-plugin/config.ts +266 -266
  114. package/src/vite-plugin/errors.ts +127 -127
  115. package/src/vite-plugin/image-optimization.ts +156 -156
  116. package/src/vite-plugin/integration-activator.ts +126 -126
  117. package/src/vite-plugin/island-sidecar-plugin.ts +176 -176
  118. package/src/vite-plugin/module-discovery.ts +189 -189
  119. package/src/vite-plugin/nitro-integration.ts +1354 -1354
  120. package/src/vite-plugin/plugin.ts +403 -409
  121. package/src/vite-plugin/types.ts +327 -327
  122. package/src/vite-plugin/validation.ts +228 -228
  123. package/src/client/adapters/index.js +0 -12
  124. package/src/client/adapters/lit-adapter.js +0 -467
  125. package/src/client/adapters/lit-adapter.ts +0 -654
  126. package/src/client/adapters/preact-adapter.js +0 -223
  127. package/src/client/adapters/preact-adapter.ts +0 -331
  128. package/src/client/adapters/qwik-adapter.js +0 -259
  129. package/src/client/adapters/qwik-adapter.ts +0 -345
  130. package/src/client/adapters/react-adapter.js +0 -220
  131. package/src/client/adapters/react-adapter.ts +0 -353
  132. package/src/client/adapters/solid-adapter.js +0 -295
  133. package/src/client/adapters/solid-adapter.ts +0 -451
  134. package/src/client/adapters/svelte-adapter.js +0 -368
  135. package/src/client/adapters/svelte-adapter.ts +0 -524
  136. package/src/client/adapters/vue-adapter.js +0 -278
  137. package/src/client/adapters/vue-adapter.ts +0 -467
  138. package/src/client/components.js +0 -23
  139. package/src/client/css-hmr-handler.js +0 -263
  140. package/src/client/framework-adapter.js +0 -283
  141. package/src/client/hmr-coordinator.js +0 -274
@@ -1,439 +1,439 @@
1
- /**
2
- * Route Discovery Module for Nitro
3
- *
4
- * This module provides minimal route discovery for Avalon's SSR pages.
5
- *
6
- * IMPORTANT: This module is simplified to complement Nitro's native routing:
7
- * - API routes: Handled by Nitro's auto-discovery from `api/` directory
8
- * - Page routes: Discovered here for SSR rendering (pages are components, not h3 handlers)
9
- * - Middleware: Handled by Nitro's auto-discovery from `middleware/` directory
10
- *
11
- * The page discovery is needed because Avalon pages are React/Vue/Svelte components
12
- * that require SSR rendering, which is different from Nitro's h3 route handlers.
13
- *
14
- * @module nitro/route-discovery
15
- */
16
-
17
- import { stat } from "node:fs/promises";
18
- import { basename, dirname, relative } from "node:path";
19
- import { walk } from "../utils/fs.ts";
20
- import type { DiscoveredRoute } from "./types.ts";
21
-
22
- /**
23
- * Supported page file extensions
24
- */
25
- export const PAGE_EXTENSIONS = [
26
- ".tsx",
27
- ".ts",
28
- ".jsx",
29
- ".js",
30
- ".vue",
31
- ".svelte",
32
- ".md",
33
- ".mdx",
34
- ];
35
-
36
- /**
37
- * Options for page route discovery
38
- */
39
- export interface PageDiscoveryOptions {
40
- /** Pages directory path (absolute or relative to project root) */
41
- pagesDir: string;
42
- /** Enable development mode logging */
43
- developmentMode?: boolean;
44
- /** Directories to exclude from scanning */
45
- excludeDirectories?: string[];
46
- }
47
-
48
- /**
49
- * Result of file path to pattern conversion
50
- */
51
- export interface FilePathPatternResult {
52
- /** Route pattern (e.g., /users/:id) */
53
- pattern: string;
54
- /** Extracted parameter names */
55
- params: string[];
56
- }
57
-
58
- /**
59
- * Discovers page routes from multiple directories with route prefixes
60
- *
61
- * This supports modular architecture where pages can be in different modules,
62
- * each with their own route prefix.
63
- *
64
- * @param pageDirs - Array of page directories with their route prefixes
65
- * @param options - Discovery options
66
- * @returns Array of discovered page routes
67
- */
68
- export async function discoverPageRoutesFromMultipleDirs(
69
- pageDirs: Array<{ dir: string; prefix: string }>,
70
- options?: Pick<PageDiscoveryOptions, "developmentMode" | "excludeDirectories">
71
- ): Promise<DiscoveredRoute[]> {
72
- const allRoutes: DiscoveredRoute[] = [];
73
-
74
- for (const { dir, prefix } of pageDirs) {
75
- const routes = await discoverPageRoutes(dir, options);
76
-
77
- // Apply prefix to routes (except for root prefix '/')
78
- for (const route of routes) {
79
- if (prefix !== '/') {
80
- // Combine prefix with pattern
81
- // /prefix + /path -> /prefix/path
82
- // /prefix + / -> /prefix
83
- const combinedPattern = route.pattern === '/'
84
- ? prefix
85
- : prefix + route.pattern;
86
- route.pattern = combinedPattern;
87
- }
88
- allRoutes.push(route);
89
- }
90
- }
91
-
92
- // Sort all routes by specificity
93
- return sortRoutesBySpecificity(allRoutes);
94
- }
95
-
96
- /**
97
- * Discovers page routes from the pages directory for SSR rendering
98
- *
99
- * NOTE: This is specifically for page components that need SSR rendering.
100
- * API routes should be placed in the `api/` directory and are auto-discovered
101
- * by Nitro's native file-system routing.
102
- *
103
- * @param pagesDir - Path to the pages directory
104
- * @param options - Discovery options
105
- * @returns Array of discovered page routes
106
- *
107
- * @example
108
- * ```ts
109
- * const routes = await discoverPageRoutes('src/pages', {
110
- * developmentMode: true,
111
- * });
112
- * ```
113
- */
114
- export async function discoverPageRoutes(
115
- pagesDir: string,
116
- options?: Pick<PageDiscoveryOptions, "developmentMode" | "excludeDirectories">
117
- ): Promise<DiscoveredRoute[]> {
118
- const routes: DiscoveredRoute[] = [];
119
- const excludeDirs = options?.excludeDirectories ?? ["node_modules", ".git"];
120
-
121
- try {
122
- // Check if directory exists
123
- const statResult = await stat(pagesDir);
124
- if (!statResult.isDirectory()) {
125
- if (options?.developmentMode) {
126
- console.warn(
127
- `[route-discovery] Pages path is not a directory: ${pagesDir}`
128
- );
129
- }
130
- return [];
131
- }
132
- } catch (error) {
133
- if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
134
- if (options?.developmentMode) {
135
- console.warn(
136
- `[route-discovery] Pages directory not found: ${pagesDir}`
137
- );
138
- }
139
- return [];
140
- }
141
- throw error;
142
- }
143
-
144
- // Walk through the pages directory
145
- const extensions = PAGE_EXTENSIONS.map((e) => e.slice(1)); // Remove leading dot
146
-
147
- for await (
148
- const entry of walk(pagesDir, {
149
- includeDirs: false,
150
- includeSymlinks: false,
151
- exts: extensions,
152
- })
153
- ) {
154
- if (!entry.isFile) continue;
155
-
156
- const relativePath = relative(pagesDir, entry.path);
157
-
158
- // Skip files in excluded directories
159
- if (excludeDirs.some((dir) => relativePath.includes(dir))) {
160
- continue;
161
- }
162
-
163
- // Skip private files (in folders starting with _)
164
- if (isPrivateFile(relativePath)) {
165
- continue;
166
- }
167
-
168
- // Convert file path to route pattern
169
- const { pattern, params } = filePathToPattern(relativePath);
170
-
171
- routes.push({
172
- type: "page",
173
- filePath: entry.path,
174
- pattern,
175
- params,
176
- });
177
- }
178
-
179
- // Sort routes by specificity (more specific routes first)
180
- return sortRoutesBySpecificity(routes);
181
- }
182
-
183
- /**
184
- * Converts a file path to a route pattern
185
- *
186
- * Handles:
187
- * - Index files (index.tsx -> /)
188
- * - Dynamic segments ([param] -> :param)
189
- * - Catch-all segments ([...slug] -> **)
190
- * - Route groups ((group) -> removed from path)
191
- *
192
- * @param filePath - Relative file path from pages directory
193
- * @returns Pattern and extracted parameter names
194
- *
195
- * @example
196
- * ```ts
197
- * filePathToPattern('users/[id].tsx')
198
- * // { pattern: '/users/:id', params: ['id'] }
199
- *
200
- * filePathToPattern('blog/[...slug].tsx')
201
- * // { pattern: '/blog/**', params: ['slug'] }
202
- * ```
203
- */
204
- export function filePathToPattern(filePath: string): FilePathPatternResult {
205
- const params: string[] = [];
206
-
207
- let pattern = filePath
208
- // Normalize path separators
209
- .replace(/\\/g, "/")
210
- // Remove file extension
211
- .replace(/\.(tsx|ts|jsx|js|vue|svelte|md|mdx)$/, "")
212
- // Remove route groups (parentheses)
213
- .replace(/\([^)]+\)\//g, "")
214
- .replace(/\([^)]+\)$/, "");
215
-
216
- // Handle index files
217
- if (basename(pattern) === "index") {
218
- pattern = dirname(pattern);
219
- if (pattern === ".") {
220
- pattern = "";
221
- }
222
- }
223
-
224
- // Convert dynamic segments [param] to :param
225
- // Convert catch-all segments [...slug] to **
226
- pattern = pattern.replace(/\[([^\]]+)\]/g, (_, param) => {
227
- if (param.startsWith("...")) {
228
- // Catch-all segment
229
- const paramName = param.slice(3);
230
- params.push(paramName);
231
- return "**";
232
- } else {
233
- // Dynamic segment
234
- params.push(param);
235
- return `:${param}`;
236
- }
237
- });
238
-
239
- // Ensure leading slash
240
- if (!pattern.startsWith("/")) {
241
- pattern = "/" + pattern;
242
- }
243
-
244
- // Handle root path
245
- if (pattern === "/" || pattern === "") {
246
- pattern = "/";
247
- }
248
-
249
- // Remove trailing slash (except for root)
250
- if (pattern.length > 1 && pattern.endsWith("/")) {
251
- pattern = pattern.slice(0, -1);
252
- }
253
-
254
- return { pattern, params };
255
- }
256
-
257
- /**
258
- * Checks if a file is in a private folder (starts with _)
259
- *
260
- * @param relativePath - Relative file path
261
- * @returns True if the file is private
262
- */
263
- export function isPrivateFile(relativePath: string): boolean {
264
- const pathParts = relativePath.split(/[/\\]/);
265
- return pathParts.some((part) => part.startsWith("_"));
266
- }
267
-
268
- /**
269
- * Calculates route specificity score for sorting
270
- * Lower score = more specific = higher priority
271
- *
272
- * @param route - Discovered route
273
- * @returns Specificity score
274
- */
275
- export function calculateRouteSpecificity(route: DiscoveredRoute): number {
276
- const segments = route.pattern.split("/").filter((s) => s.length > 0);
277
- let score = 0;
278
-
279
- for (const segment of segments) {
280
- if (segment === "**") {
281
- // Catch-all has lowest priority
282
- score += 1000;
283
- } else if (segment.startsWith(":")) {
284
- // Dynamic segment has medium priority
285
- score += 100;
286
- } else {
287
- // Static segment has highest priority
288
- score += 1;
289
- }
290
- }
291
-
292
- // Shorter paths are more specific (for same type of segments)
293
- score += segments.length;
294
-
295
- return score;
296
- }
297
-
298
- /**
299
- * Sorts routes by specificity (most specific first)
300
- *
301
- * @param routes - Array of discovered routes
302
- * @returns Sorted array of routes
303
- */
304
- export function sortRoutesBySpecificity(
305
- routes: DiscoveredRoute[]
306
- ): DiscoveredRoute[] {
307
- return [...routes].sort((a, b) => {
308
- const scoreA = calculateRouteSpecificity(a);
309
- const scoreB = calculateRouteSpecificity(b);
310
- return scoreA - scoreB;
311
- });
312
- }
313
-
314
- /**
315
- * Validates a route pattern for correctness
316
- *
317
- * @param pattern - Route pattern to validate
318
- * @returns Array of validation errors (empty if valid)
319
- */
320
- export function validateRoutePattern(pattern: string): string[] {
321
- const errors: string[] = [];
322
-
323
- // Check for leading slash
324
- if (!pattern.startsWith("/")) {
325
- errors.push("Route pattern must start with /");
326
- }
327
-
328
- // Check for malformed dynamic segments
329
- const malformedSegments = pattern.match(/\[[^\]]*$/g);
330
- if (malformedSegments) {
331
- errors.push(
332
- `Malformed dynamic segments: ${malformedSegments.join(", ")}. Dynamic segments must be properly closed with ]`
333
- );
334
- }
335
-
336
- // Check for empty dynamic segments
337
- const emptySegments = pattern.match(/\[\]/g);
338
- if (emptySegments) {
339
- errors.push(
340
- "Empty dynamic segments [] are not allowed. Use [param] for dynamic segments or [...rest] for catch-all"
341
- );
342
- }
343
-
344
- // Check for nested dynamic segments
345
- const nestedSegments = pattern.match(/\[[^\]]*\[[^\]]*\]/g);
346
- if (nestedSegments) {
347
- errors.push(
348
- `Nested dynamic segments are not supported: ${nestedSegments.join(", ")}`
349
- );
350
- }
351
-
352
- return errors;
353
- }
354
-
355
- /**
356
- * Extracts parameter names from a route pattern
357
- *
358
- * @param pattern - Route pattern (e.g., /users/:id/posts/:postId)
359
- * @returns Array of parameter names
360
- */
361
- export function extractParamsFromPattern(pattern: string): string[] {
362
- const params: string[] = [];
363
-
364
- // Match :param patterns
365
- const dynamicMatches = pattern.matchAll(/:([^/]+)/g);
366
- for (const match of dynamicMatches) {
367
- params.push(match[1]);
368
- }
369
-
370
- // Match ** catch-all (represented as unnamed param)
371
- if (pattern.includes("**")) {
372
- params.push("slug");
373
- }
374
-
375
- return params;
376
- }
377
-
378
- /**
379
- * Checks if a route pattern matches a given URL path
380
- *
381
- * @param pattern - Route pattern
382
- * @param path - URL path to match
383
- * @returns True if the pattern matches the path
384
- */
385
- export function matchRoutePattern(
386
- pattern: string,
387
- path: string
388
- ): { matches: boolean; params: Record<string, string> } {
389
- const params: Record<string, string> = {};
390
-
391
- // Normalize paths
392
- const normalizedPattern = pattern.replace(/\/$/, "") || "/";
393
- const normalizedPath = path.replace(/\/$/, "") || "/";
394
-
395
- const patternSegments = normalizedPattern.split("/").filter((s) => s);
396
- const pathSegments = normalizedPath.split("/").filter((s) => s);
397
-
398
- let patternIndex = 0;
399
- let pathIndex = 0;
400
-
401
- while (patternIndex < patternSegments.length) {
402
- const patternSegment = patternSegments[patternIndex];
403
-
404
- if (patternSegment === "**") {
405
- // Catch-all: match remaining path segments
406
- const remainingPath = pathSegments.slice(pathIndex).join("/");
407
- params["slug"] = remainingPath;
408
- return { matches: true, params };
409
- }
410
-
411
- if (pathIndex >= pathSegments.length) {
412
- // No more path segments but pattern has more
413
- return { matches: false, params: {} };
414
- }
415
-
416
- const pathSegment = pathSegments[pathIndex];
417
-
418
- if (patternSegment.startsWith(":")) {
419
- // Dynamic segment: extract param value
420
- const paramName = patternSegment.slice(1);
421
- params[paramName] = pathSegment;
422
- } else if (patternSegment !== pathSegment) {
423
- // Static segment: must match exactly
424
- return { matches: false, params: {} };
425
- }
426
-
427
- patternIndex++;
428
- pathIndex++;
429
- }
430
-
431
- // Check if all path segments were consumed
432
- if (pathIndex < pathSegments.length) {
433
- return { matches: false, params: {} };
434
- }
435
-
436
- return { matches: true, params };
437
- }
438
-
439
-
1
+ /**
2
+ * Route Discovery Module for Nitro
3
+ *
4
+ * This module provides minimal route discovery for Avalon's SSR pages.
5
+ *
6
+ * IMPORTANT: This module is simplified to complement Nitro's native routing:
7
+ * - API routes: Handled by Nitro's auto-discovery from `api/` directory
8
+ * - Page routes: Discovered here for SSR rendering (pages are components, not h3 handlers)
9
+ * - Middleware: Handled by Nitro's auto-discovery from `middleware/` directory
10
+ *
11
+ * The page discovery is needed because Avalon pages are React/Vue/Svelte components
12
+ * that require SSR rendering, which is different from Nitro's h3 route handlers.
13
+ *
14
+ * @module nitro/route-discovery
15
+ */
16
+
17
+ import { stat } from "node:fs/promises";
18
+ import { basename, dirname, relative } from "node:path";
19
+ import { walk } from "../utils/fs.ts";
20
+ import type { DiscoveredRoute } from "./types.ts";
21
+
22
+ /**
23
+ * Supported page file extensions
24
+ */
25
+ export const PAGE_EXTENSIONS = [
26
+ ".tsx",
27
+ ".ts",
28
+ ".jsx",
29
+ ".js",
30
+ ".vue",
31
+ ".svelte",
32
+ ".md",
33
+ ".mdx",
34
+ ];
35
+
36
+ /**
37
+ * Options for page route discovery
38
+ */
39
+ export interface PageDiscoveryOptions {
40
+ /** Pages directory path (absolute or relative to project root) */
41
+ pagesDir: string;
42
+ /** Enable development mode logging */
43
+ developmentMode?: boolean;
44
+ /** Directories to exclude from scanning */
45
+ excludeDirectories?: string[];
46
+ }
47
+
48
+ /**
49
+ * Result of file path to pattern conversion
50
+ */
51
+ export interface FilePathPatternResult {
52
+ /** Route pattern (e.g., /users/:id) */
53
+ pattern: string;
54
+ /** Extracted parameter names */
55
+ params: string[];
56
+ }
57
+
58
+ /**
59
+ * Discovers page routes from multiple directories with route prefixes
60
+ *
61
+ * This supports modular architecture where pages can be in different modules,
62
+ * each with their own route prefix.
63
+ *
64
+ * @param pageDirs - Array of page directories with their route prefixes
65
+ * @param options - Discovery options
66
+ * @returns Array of discovered page routes
67
+ */
68
+ export async function discoverPageRoutesFromMultipleDirs(
69
+ pageDirs: Array<{ dir: string; prefix: string }>,
70
+ options?: Pick<PageDiscoveryOptions, "developmentMode" | "excludeDirectories">
71
+ ): Promise<DiscoveredRoute[]> {
72
+ const allRoutes: DiscoveredRoute[] = [];
73
+
74
+ for (const { dir, prefix } of pageDirs) {
75
+ const routes = await discoverPageRoutes(dir, options);
76
+
77
+ // Apply prefix to routes (except for root prefix '/')
78
+ for (const route of routes) {
79
+ if (prefix !== '/') {
80
+ // Combine prefix with pattern
81
+ // /prefix + /path -> /prefix/path
82
+ // /prefix + / -> /prefix
83
+ const combinedPattern = route.pattern === '/'
84
+ ? prefix
85
+ : prefix + route.pattern;
86
+ route.pattern = combinedPattern;
87
+ }
88
+ allRoutes.push(route);
89
+ }
90
+ }
91
+
92
+ // Sort all routes by specificity
93
+ return sortRoutesBySpecificity(allRoutes);
94
+ }
95
+
96
+ /**
97
+ * Discovers page routes from the pages directory for SSR rendering
98
+ *
99
+ * NOTE: This is specifically for page components that need SSR rendering.
100
+ * API routes should be placed in the `api/` directory and are auto-discovered
101
+ * by Nitro's native file-system routing.
102
+ *
103
+ * @param pagesDir - Path to the pages directory
104
+ * @param options - Discovery options
105
+ * @returns Array of discovered page routes
106
+ *
107
+ * @example
108
+ * ```ts
109
+ * const routes = await discoverPageRoutes('src/pages', {
110
+ * developmentMode: true,
111
+ * });
112
+ * ```
113
+ */
114
+ export async function discoverPageRoutes(
115
+ pagesDir: string,
116
+ options?: Pick<PageDiscoveryOptions, "developmentMode" | "excludeDirectories">
117
+ ): Promise<DiscoveredRoute[]> {
118
+ const routes: DiscoveredRoute[] = [];
119
+ const excludeDirs = options?.excludeDirectories ?? ["node_modules", ".git"];
120
+
121
+ try {
122
+ // Check if directory exists
123
+ const statResult = await stat(pagesDir);
124
+ if (!statResult.isDirectory()) {
125
+ if (options?.developmentMode) {
126
+ console.warn(
127
+ `[route-discovery] Pages path is not a directory: ${pagesDir}`
128
+ );
129
+ }
130
+ return [];
131
+ }
132
+ } catch (error) {
133
+ if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') {
134
+ if (options?.developmentMode) {
135
+ console.warn(
136
+ `[route-discovery] Pages directory not found: ${pagesDir}`
137
+ );
138
+ }
139
+ return [];
140
+ }
141
+ throw error;
142
+ }
143
+
144
+ // Walk through the pages directory
145
+ const extensions = PAGE_EXTENSIONS.map((e) => e.slice(1)); // Remove leading dot
146
+
147
+ for await (
148
+ const entry of walk(pagesDir, {
149
+ includeDirs: false,
150
+ includeSymlinks: false,
151
+ exts: extensions,
152
+ })
153
+ ) {
154
+ if (!entry.isFile) continue;
155
+
156
+ const relativePath = relative(pagesDir, entry.path);
157
+
158
+ // Skip files in excluded directories
159
+ if (excludeDirs.some((dir) => relativePath.includes(dir))) {
160
+ continue;
161
+ }
162
+
163
+ // Skip private files (in folders starting with _)
164
+ if (isPrivateFile(relativePath)) {
165
+ continue;
166
+ }
167
+
168
+ // Convert file path to route pattern
169
+ const { pattern, params } = filePathToPattern(relativePath);
170
+
171
+ routes.push({
172
+ type: "page",
173
+ filePath: entry.path,
174
+ pattern,
175
+ params,
176
+ });
177
+ }
178
+
179
+ // Sort routes by specificity (more specific routes first)
180
+ return sortRoutesBySpecificity(routes);
181
+ }
182
+
183
+ /**
184
+ * Converts a file path to a route pattern
185
+ *
186
+ * Handles:
187
+ * - Index files (index.tsx -> /)
188
+ * - Dynamic segments ([param] -> :param)
189
+ * - Catch-all segments ([...slug] -> **)
190
+ * - Route groups ((group) -> removed from path)
191
+ *
192
+ * @param filePath - Relative file path from pages directory
193
+ * @returns Pattern and extracted parameter names
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * filePathToPattern('users/[id].tsx')
198
+ * // { pattern: '/users/:id', params: ['id'] }
199
+ *
200
+ * filePathToPattern('blog/[...slug].tsx')
201
+ * // { pattern: '/blog/**', params: ['slug'] }
202
+ * ```
203
+ */
204
+ export function filePathToPattern(filePath: string): FilePathPatternResult {
205
+ const params: string[] = [];
206
+
207
+ let pattern = filePath
208
+ // Normalize path separators
209
+ .replace(/\\/g, "/")
210
+ // Remove file extension
211
+ .replace(/\.(tsx|ts|jsx|js|vue|svelte|md|mdx)$/, "")
212
+ // Remove route groups (parentheses)
213
+ .replace(/\([^)]+\)\//g, "")
214
+ .replace(/\([^)]+\)$/, "");
215
+
216
+ // Handle index files
217
+ if (basename(pattern) === "index") {
218
+ pattern = dirname(pattern);
219
+ if (pattern === ".") {
220
+ pattern = "";
221
+ }
222
+ }
223
+
224
+ // Convert dynamic segments [param] to :param
225
+ // Convert catch-all segments [...slug] to **
226
+ pattern = pattern.replace(/\[([^\]]+)\]/g, (_, param) => {
227
+ if (param.startsWith("...")) {
228
+ // Catch-all segment
229
+ const paramName = param.slice(3);
230
+ params.push(paramName);
231
+ return "**";
232
+ } else {
233
+ // Dynamic segment
234
+ params.push(param);
235
+ return `:${param}`;
236
+ }
237
+ });
238
+
239
+ // Ensure leading slash
240
+ if (!pattern.startsWith("/")) {
241
+ pattern = "/" + pattern;
242
+ }
243
+
244
+ // Handle root path
245
+ if (pattern === "/" || pattern === "") {
246
+ pattern = "/";
247
+ }
248
+
249
+ // Remove trailing slash (except for root)
250
+ if (pattern.length > 1 && pattern.endsWith("/")) {
251
+ pattern = pattern.slice(0, -1);
252
+ }
253
+
254
+ return { pattern, params };
255
+ }
256
+
257
+ /**
258
+ * Checks if a file is in a private folder (starts with _)
259
+ *
260
+ * @param relativePath - Relative file path
261
+ * @returns True if the file is private
262
+ */
263
+ export function isPrivateFile(relativePath: string): boolean {
264
+ const pathParts = relativePath.split(/[/\\]/);
265
+ return pathParts.some((part) => part.startsWith("_"));
266
+ }
267
+
268
+ /**
269
+ * Calculates route specificity score for sorting
270
+ * Lower score = more specific = higher priority
271
+ *
272
+ * @param route - Discovered route
273
+ * @returns Specificity score
274
+ */
275
+ export function calculateRouteSpecificity(route: DiscoveredRoute): number {
276
+ const segments = route.pattern.split("/").filter((s) => s.length > 0);
277
+ let score = 0;
278
+
279
+ for (const segment of segments) {
280
+ if (segment === "**") {
281
+ // Catch-all has lowest priority
282
+ score += 1000;
283
+ } else if (segment.startsWith(":")) {
284
+ // Dynamic segment has medium priority
285
+ score += 100;
286
+ } else {
287
+ // Static segment has highest priority
288
+ score += 1;
289
+ }
290
+ }
291
+
292
+ // Shorter paths are more specific (for same type of segments)
293
+ score += segments.length;
294
+
295
+ return score;
296
+ }
297
+
298
+ /**
299
+ * Sorts routes by specificity (most specific first)
300
+ *
301
+ * @param routes - Array of discovered routes
302
+ * @returns Sorted array of routes
303
+ */
304
+ export function sortRoutesBySpecificity(
305
+ routes: DiscoveredRoute[]
306
+ ): DiscoveredRoute[] {
307
+ return [...routes].sort((a, b) => {
308
+ const scoreA = calculateRouteSpecificity(a);
309
+ const scoreB = calculateRouteSpecificity(b);
310
+ return scoreA - scoreB;
311
+ });
312
+ }
313
+
314
+ /**
315
+ * Validates a route pattern for correctness
316
+ *
317
+ * @param pattern - Route pattern to validate
318
+ * @returns Array of validation errors (empty if valid)
319
+ */
320
+ export function validateRoutePattern(pattern: string): string[] {
321
+ const errors: string[] = [];
322
+
323
+ // Check for leading slash
324
+ if (!pattern.startsWith("/")) {
325
+ errors.push("Route pattern must start with /");
326
+ }
327
+
328
+ // Check for malformed dynamic segments
329
+ const malformedSegments = pattern.match(/\[[^\]]*$/g);
330
+ if (malformedSegments) {
331
+ errors.push(
332
+ `Malformed dynamic segments: ${malformedSegments.join(", ")}. Dynamic segments must be properly closed with ]`
333
+ );
334
+ }
335
+
336
+ // Check for empty dynamic segments
337
+ const emptySegments = pattern.match(/\[\]/g);
338
+ if (emptySegments) {
339
+ errors.push(
340
+ "Empty dynamic segments [] are not allowed. Use [param] for dynamic segments or [...rest] for catch-all"
341
+ );
342
+ }
343
+
344
+ // Check for nested dynamic segments
345
+ const nestedSegments = pattern.match(/\[[^\]]*\[[^\]]*\]/g);
346
+ if (nestedSegments) {
347
+ errors.push(
348
+ `Nested dynamic segments are not supported: ${nestedSegments.join(", ")}`
349
+ );
350
+ }
351
+
352
+ return errors;
353
+ }
354
+
355
+ /**
356
+ * Extracts parameter names from a route pattern
357
+ *
358
+ * @param pattern - Route pattern (e.g., /users/:id/posts/:postId)
359
+ * @returns Array of parameter names
360
+ */
361
+ export function extractParamsFromPattern(pattern: string): string[] {
362
+ const params: string[] = [];
363
+
364
+ // Match :param patterns
365
+ const dynamicMatches = pattern.matchAll(/:([^/]+)/g);
366
+ for (const match of dynamicMatches) {
367
+ params.push(match[1]);
368
+ }
369
+
370
+ // Match ** catch-all (represented as unnamed param)
371
+ if (pattern.includes("**")) {
372
+ params.push("slug");
373
+ }
374
+
375
+ return params;
376
+ }
377
+
378
+ /**
379
+ * Checks if a route pattern matches a given URL path
380
+ *
381
+ * @param pattern - Route pattern
382
+ * @param path - URL path to match
383
+ * @returns True if the pattern matches the path
384
+ */
385
+ export function matchRoutePattern(
386
+ pattern: string,
387
+ path: string
388
+ ): { matches: boolean; params: Record<string, string> } {
389
+ const params: Record<string, string> = {};
390
+
391
+ // Normalize paths
392
+ const normalizedPattern = pattern.replace(/\/$/, "") || "/";
393
+ const normalizedPath = path.replace(/\/$/, "") || "/";
394
+
395
+ const patternSegments = normalizedPattern.split("/").filter((s) => s);
396
+ const pathSegments = normalizedPath.split("/").filter((s) => s);
397
+
398
+ let patternIndex = 0;
399
+ let pathIndex = 0;
400
+
401
+ while (patternIndex < patternSegments.length) {
402
+ const patternSegment = patternSegments[patternIndex];
403
+
404
+ if (patternSegment === "**") {
405
+ // Catch-all: match remaining path segments
406
+ const remainingPath = pathSegments.slice(pathIndex).join("/");
407
+ params["slug"] = remainingPath;
408
+ return { matches: true, params };
409
+ }
410
+
411
+ if (pathIndex >= pathSegments.length) {
412
+ // No more path segments but pattern has more
413
+ return { matches: false, params: {} };
414
+ }
415
+
416
+ const pathSegment = pathSegments[pathIndex];
417
+
418
+ if (patternSegment.startsWith(":")) {
419
+ // Dynamic segment: extract param value
420
+ const paramName = patternSegment.slice(1);
421
+ params[paramName] = pathSegment;
422
+ } else if (patternSegment !== pathSegment) {
423
+ // Static segment: must match exactly
424
+ return { matches: false, params: {} };
425
+ }
426
+
427
+ patternIndex++;
428
+ pathIndex++;
429
+ }
430
+
431
+ // Check if all path segments were consumed
432
+ if (pathIndex < pathSegments.length) {
433
+ return { matches: false, params: {} };
434
+ }
435
+
436
+ return { matches: true, params };
437
+ }
438
+
439
+