@bromscandium/vite-plugin 1.0.0 → 1.0.2

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/src/scanner.ts CHANGED
@@ -1,283 +1,283 @@
1
- /**
2
- * File system scanner for discovering page routes.
3
- * @module
4
- */
5
-
6
- import * as fs from 'fs';
7
- import * as path from 'path';
8
-
9
- /**
10
- * Information about a scanned route from the file system.
11
- */
12
- export interface ScannedRoute {
13
- /** Absolute path to the page file */
14
- filePath: string;
15
- /** URL path for the route (e.g., '/users/:id') */
16
- routePath: string;
17
- /** Whether the route contains dynamic segments */
18
- isDynamic: boolean;
19
- /** Names of dynamic parameters in the route */
20
- params: string[];
21
- /** Whether this is a page component (page.tsx) */
22
- isPage: boolean;
23
- /** Whether this is a layout component */
24
- isLayout: boolean;
25
- /** Whether this is a catch-all route ([...slug]) */
26
- isCatchAll: boolean;
27
- /** URL path segments */
28
- segments: string[];
29
- /** Path to the layout file if one exists for this route */
30
- layoutPath?: string;
31
- /** File system relative path (includes route groups) */
32
- fsPath: string;
33
- }
34
-
35
- const VALID_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];
36
- const IGNORED_FILES = ['_app', '_document', '_error', '404', '500'];
37
- const IGNORED_PREFIXES = ['_', '.'];
38
-
39
- const layoutsMap = new Map<string, string>();
40
-
41
- /**
42
- * Scans a pages directory and returns all discovered routes.
43
- * Supports Next.js-style file-based routing conventions including:
44
- * - `page.tsx` files for page components
45
- * - `layout.tsx` files for layout wrappers
46
- * - `[param]` for dynamic route segments
47
- * - `[...slug]` for catch-all routes
48
- * - `(group)` folders for route grouping without URL segments
49
- *
50
- * @param pagesDir - The directory to scan for pages
51
- * @returns An array of scanned route information, sorted by priority
52
- *
53
- * @example
54
- * ```ts
55
- * const routes = scanPages('./src/pages');
56
- * // Returns routes sorted: static > dynamic > catch-all
57
- * ```
58
- */
59
- export function scanPages(pagesDir: string): ScannedRoute[] {
60
- if (!fs.existsSync(pagesDir)) {
61
- return [];
62
- }
63
-
64
- layoutsMap.clear();
65
-
66
- scanForLayouts(pagesDir, '');
67
-
68
- const routes: ScannedRoute[] = [];
69
- scanDirectory(pagesDir, '', '', routes);
70
-
71
- for (const route of routes) {
72
- const layout = findLayoutForRoute(route.fsPath);
73
- if (layout) {
74
- route.layoutPath = layout;
75
- }
76
- }
77
-
78
- return routes.sort((a, b) => {
79
- if (a.isCatchAll !== b.isCatchAll) {
80
- return a.isCatchAll ? 1 : -1;
81
- }
82
-
83
- if (a.isDynamic !== b.isDynamic) {
84
- return a.isDynamic ? 1 : -1;
85
- }
86
-
87
- const aSegments = a.routePath.split('/').filter(Boolean).length;
88
- const bSegments = b.routePath.split('/').filter(Boolean).length;
89
- if (aSegments !== bSegments) {
90
- return aSegments - bSegments;
91
- }
92
-
93
- return a.routePath.localeCompare(b.routePath);
94
- });
95
- }
96
-
97
- function scanForLayouts(dir: string, fsRelativePath: string): void {
98
- const entries = fs.readdirSync(dir, { withFileTypes: true });
99
-
100
- for (const entry of entries) {
101
- const name = entry.name;
102
-
103
- if (IGNORED_PREFIXES.some(prefix => name.startsWith(prefix))) {
104
- continue;
105
- }
106
-
107
- const fullPath = path.join(dir, name);
108
-
109
- if (entry.isDirectory()) {
110
- const newFsPath = fsRelativePath ? `${fsRelativePath}/${name}` : name;
111
- scanForLayouts(fullPath, newFsPath);
112
- } else if (entry.isFile()) {
113
- const ext = path.extname(name);
114
- if (!VALID_EXTENSIONS.includes(ext)) continue;
115
-
116
- const baseName = path.basename(name, ext);
117
- if (baseName === 'layout' || baseName === '_layout') {
118
- const fsPath = '/' + fsRelativePath.replace(/\\/g, '/');
119
- layoutsMap.set(fsPath.replace(/\/+/g, '/') || '/', fullPath.replace(/\\/g, '/'));
120
- }
121
- }
122
- }
123
- }
124
-
125
- function findLayoutForRoute(fsPath: string): string | undefined {
126
- let current = fsPath;
127
-
128
- while (current) {
129
- const layout = layoutsMap.get(current);
130
- if (layout) return layout;
131
-
132
- const lastSlash = current.lastIndexOf('/');
133
- if (lastSlash <= 0) break;
134
- current = current.substring(0, lastSlash);
135
- }
136
-
137
- return layoutsMap.get('/');
138
- }
139
-
140
- function isRouteGroup(name: string): boolean {
141
- return name.startsWith('(') && name.endsWith(')');
142
- }
143
-
144
- function scanDirectory(
145
- dir: string,
146
- routeRelativePath: string,
147
- fsRelativePath: string,
148
- routes: ScannedRoute[]
149
- ): void {
150
- const entries = fs.readdirSync(dir, { withFileTypes: true });
151
-
152
- for (const entry of entries) {
153
- const name = entry.name;
154
-
155
- if (IGNORED_PREFIXES.some(prefix => name.startsWith(prefix))) {
156
- continue;
157
- }
158
-
159
- const fullPath = path.join(dir, name);
160
-
161
- if (entry.isDirectory()) {
162
- const routeSegment = isRouteGroup(name) ? '' : name;
163
- const newRoutePath = routeSegment
164
- ? (routeRelativePath ? `${routeRelativePath}/${routeSegment}` : routeSegment)
165
- : routeRelativePath;
166
- const newFsPath = fsRelativePath ? `${fsRelativePath}/${name}` : name;
167
- scanDirectory(fullPath, newRoutePath, newFsPath, routes);
168
- } else if (entry.isFile()) {
169
- const ext = path.extname(name);
170
- if (!VALID_EXTENSIONS.includes(ext)) {
171
- continue;
172
- }
173
-
174
- const baseName = path.basename(name, ext);
175
-
176
- if (IGNORED_FILES.includes(baseName)) {
177
- continue;
178
- }
179
-
180
- if (baseName === '_layout' || baseName === 'layout') {
181
- continue;
182
- }
183
-
184
- const route = parseRoute(fullPath, routeRelativePath, fsRelativePath, baseName);
185
- if (route) {
186
- routes.push(route);
187
- }
188
- }
189
- }
190
- }
191
-
192
- function parseRoute(
193
- filePath: string,
194
- routeRelativePath: string,
195
- fsRelativePath: string,
196
- baseName: string
197
- ): ScannedRoute | null {
198
- const isPage = baseName === 'page';
199
- const isLayout = baseName === '_layout' || baseName === 'layout';
200
-
201
- let routePath = '/' + routeRelativePath.replace(/\\/g, '/');
202
-
203
- if (!isPage) {
204
- routePath = routePath === '/'
205
- ? `/${baseName}`
206
- : `${routePath}/${baseName}`;
207
- }
208
-
209
- const fsPath = '/' + fsRelativePath.replace(/\\/g, '/');
210
-
211
- const params: string[] = [];
212
- let isDynamic = false;
213
- let isCatchAll = false;
214
-
215
- routePath = routePath.replace(/\[\.\.\.(\w+)]/g, (_, paramName) => {
216
- params.push(paramName);
217
- isDynamic = true;
218
- isCatchAll = true;
219
- return `:${paramName}*`;
220
- });
221
-
222
- routePath = routePath.replace(/\[(\w+)]/g, (_, paramName) => {
223
- params.push(paramName);
224
- isDynamic = true;
225
- return `:${paramName}`;
226
- });
227
-
228
- routePath = routePath.replace(/\/+/g, '/');
229
- if (routePath.length > 1 && routePath.endsWith('/')) {
230
- routePath = routePath.slice(0, -1);
231
- }
232
-
233
- const segments = routePath.split('/').filter(Boolean);
234
-
235
- return {
236
- filePath: filePath.replace(/\\/g, '/'),
237
- routePath,
238
- isDynamic,
239
- params,
240
- isPage,
241
- isLayout,
242
- isCatchAll,
243
- segments,
244
- fsPath: fsPath.replace(/\/+/g, '/') || '/',
245
- };
246
- }
247
-
248
- /**
249
- * Watches a pages directory for changes and invokes a callback when files change.
250
- *
251
- * @param pagesDir - The directory to watch
252
- * @param onChange - Callback invoked when a page file changes
253
- * @returns A function to stop watching
254
- *
255
- * @example
256
- * ```ts
257
- * const stop = watchPages('./src/pages', () => {
258
- * console.log('Pages changed, regenerating routes...');
259
- * });
260
- *
261
- * // Later: stop watching
262
- * stop();
263
- * ```
264
- */
265
- export function watchPages(
266
- pagesDir: string,
267
- onChange: () => void
268
- ): () => void {
269
- if (!fs.existsSync(pagesDir)) {
270
- fs.mkdirSync(pagesDir, { recursive: true });
271
- }
272
-
273
- const watcher = fs.watch(pagesDir, { recursive: true }, (_eventType, filename) => {
274
- if (!filename) return;
275
-
276
- const ext = path.extname(filename);
277
- if (VALID_EXTENSIONS.includes(ext)) {
278
- onChange();
279
- }
280
- });
281
-
282
- return () => watcher.close();
283
- }
1
+ /**
2
+ * File system scanner for discovering page routes.
3
+ * @module
4
+ */
5
+
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+
9
+ /**
10
+ * Information about a scanned route from the file system.
11
+ */
12
+ export interface ScannedRoute {
13
+ /** Absolute path to the page file */
14
+ filePath: string;
15
+ /** URL path for the route (e.g., '/users/:id') */
16
+ routePath: string;
17
+ /** Whether the route contains dynamic segments */
18
+ isDynamic: boolean;
19
+ /** Names of dynamic parameters in the route */
20
+ params: string[];
21
+ /** Whether this is a page component (page.tsx) */
22
+ isPage: boolean;
23
+ /** Whether this is a layout component */
24
+ isLayout: boolean;
25
+ /** Whether this is a catch-all route ([...slug]) */
26
+ isCatchAll: boolean;
27
+ /** URL path segments */
28
+ segments: string[];
29
+ /** Path to the layout file if one exists for this route */
30
+ layoutPath?: string;
31
+ /** File system relative path (includes route groups) */
32
+ fsPath: string;
33
+ }
34
+
35
+ const VALID_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];
36
+ const IGNORED_FILES = ['_app', '_document', '_error', '404', '500'];
37
+ const IGNORED_PREFIXES = ['_', '.'];
38
+
39
+ const layoutsMap = new Map<string, string>();
40
+
41
+ /**
42
+ * Scans a pages directory and returns all discovered routes.
43
+ * Supports Next.js-style file-based routing conventions including:
44
+ * - `page.tsx` files for page components
45
+ * - `layout.tsx` files for layout wrappers
46
+ * - `[param]` for dynamic route segments
47
+ * - `[...slug]` for catch-all routes
48
+ * - `(group)` folders for route grouping without URL segments
49
+ *
50
+ * @param pagesDir - The directory to scan for pages
51
+ * @returns An array of scanned route information, sorted by priority
52
+ *
53
+ * @example
54
+ * ```ts
55
+ * const routes = scanPages('./src/pages');
56
+ * // Returns routes sorted: static > dynamic > catch-all
57
+ * ```
58
+ */
59
+ export function scanPages(pagesDir: string): ScannedRoute[] {
60
+ if (!fs.existsSync(pagesDir)) {
61
+ return [];
62
+ }
63
+
64
+ layoutsMap.clear();
65
+
66
+ scanForLayouts(pagesDir, '');
67
+
68
+ const routes: ScannedRoute[] = [];
69
+ scanDirectory(pagesDir, '', '', routes);
70
+
71
+ for (const route of routes) {
72
+ const layout = findLayoutForRoute(route.fsPath);
73
+ if (layout) {
74
+ route.layoutPath = layout;
75
+ }
76
+ }
77
+
78
+ return routes.sort((a, b) => {
79
+ if (a.isCatchAll !== b.isCatchAll) {
80
+ return a.isCatchAll ? 1 : -1;
81
+ }
82
+
83
+ if (a.isDynamic !== b.isDynamic) {
84
+ return a.isDynamic ? 1 : -1;
85
+ }
86
+
87
+ const aSegments = a.routePath.split('/').filter(Boolean).length;
88
+ const bSegments = b.routePath.split('/').filter(Boolean).length;
89
+ if (aSegments !== bSegments) {
90
+ return aSegments - bSegments;
91
+ }
92
+
93
+ return a.routePath.localeCompare(b.routePath);
94
+ });
95
+ }
96
+
97
+ function scanForLayouts(dir: string, fsRelativePath: string): void {
98
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
99
+
100
+ for (const entry of entries) {
101
+ const name = entry.name;
102
+
103
+ if (IGNORED_PREFIXES.some(prefix => name.startsWith(prefix))) {
104
+ continue;
105
+ }
106
+
107
+ const fullPath = path.join(dir, name);
108
+
109
+ if (entry.isDirectory()) {
110
+ const newFsPath = fsRelativePath ? `${fsRelativePath}/${name}` : name;
111
+ scanForLayouts(fullPath, newFsPath);
112
+ } else if (entry.isFile()) {
113
+ const ext = path.extname(name);
114
+ if (!VALID_EXTENSIONS.includes(ext)) continue;
115
+
116
+ const baseName = path.basename(name, ext);
117
+ if (baseName === 'layout' || baseName === '_layout') {
118
+ const fsPath = '/' + fsRelativePath.replace(/\\/g, '/');
119
+ layoutsMap.set(fsPath.replace(/\/+/g, '/') || '/', fullPath.replace(/\\/g, '/'));
120
+ }
121
+ }
122
+ }
123
+ }
124
+
125
+ function findLayoutForRoute(fsPath: string): string | undefined {
126
+ let current = fsPath;
127
+
128
+ while (current) {
129
+ const layout = layoutsMap.get(current);
130
+ if (layout) return layout;
131
+
132
+ const lastSlash = current.lastIndexOf('/');
133
+ if (lastSlash <= 0) break;
134
+ current = current.substring(0, lastSlash);
135
+ }
136
+
137
+ return layoutsMap.get('/');
138
+ }
139
+
140
+ function isRouteGroup(name: string): boolean {
141
+ return name.startsWith('(') && name.endsWith(')');
142
+ }
143
+
144
+ function scanDirectory(
145
+ dir: string,
146
+ routeRelativePath: string,
147
+ fsRelativePath: string,
148
+ routes: ScannedRoute[]
149
+ ): void {
150
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
151
+
152
+ for (const entry of entries) {
153
+ const name = entry.name;
154
+
155
+ if (IGNORED_PREFIXES.some(prefix => name.startsWith(prefix))) {
156
+ continue;
157
+ }
158
+
159
+ const fullPath = path.join(dir, name);
160
+
161
+ if (entry.isDirectory()) {
162
+ const routeSegment = isRouteGroup(name) ? '' : name;
163
+ const newRoutePath = routeSegment
164
+ ? (routeRelativePath ? `${routeRelativePath}/${routeSegment}` : routeSegment)
165
+ : routeRelativePath;
166
+ const newFsPath = fsRelativePath ? `${fsRelativePath}/${name}` : name;
167
+ scanDirectory(fullPath, newRoutePath, newFsPath, routes);
168
+ } else if (entry.isFile()) {
169
+ const ext = path.extname(name);
170
+ if (!VALID_EXTENSIONS.includes(ext)) {
171
+ continue;
172
+ }
173
+
174
+ const baseName = path.basename(name, ext);
175
+
176
+ if (IGNORED_FILES.includes(baseName)) {
177
+ continue;
178
+ }
179
+
180
+ if (baseName === '_layout' || baseName === 'layout') {
181
+ continue;
182
+ }
183
+
184
+ const route = parseRoute(fullPath, routeRelativePath, fsRelativePath, baseName);
185
+ if (route) {
186
+ routes.push(route);
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ function parseRoute(
193
+ filePath: string,
194
+ routeRelativePath: string,
195
+ fsRelativePath: string,
196
+ baseName: string
197
+ ): ScannedRoute | null {
198
+ const isPage = baseName === 'page';
199
+ const isLayout = baseName === '_layout' || baseName === 'layout';
200
+
201
+ let routePath = '/' + routeRelativePath.replace(/\\/g, '/');
202
+
203
+ if (!isPage) {
204
+ routePath = routePath === '/'
205
+ ? `/${baseName}`
206
+ : `${routePath}/${baseName}`;
207
+ }
208
+
209
+ const fsPath = '/' + fsRelativePath.replace(/\\/g, '/');
210
+
211
+ const params: string[] = [];
212
+ let isDynamic = false;
213
+ let isCatchAll = false;
214
+
215
+ routePath = routePath.replace(/\[\.\.\.(\w+)]/g, (_, paramName) => {
216
+ params.push(paramName);
217
+ isDynamic = true;
218
+ isCatchAll = true;
219
+ return `:${paramName}*`;
220
+ });
221
+
222
+ routePath = routePath.replace(/\[(\w+)]/g, (_, paramName) => {
223
+ params.push(paramName);
224
+ isDynamic = true;
225
+ return `:${paramName}`;
226
+ });
227
+
228
+ routePath = routePath.replace(/\/+/g, '/');
229
+ if (routePath.length > 1 && routePath.endsWith('/')) {
230
+ routePath = routePath.slice(0, -1);
231
+ }
232
+
233
+ const segments = routePath.split('/').filter(Boolean);
234
+
235
+ return {
236
+ filePath: filePath.replace(/\\/g, '/'),
237
+ routePath,
238
+ isDynamic,
239
+ params,
240
+ isPage,
241
+ isLayout,
242
+ isCatchAll,
243
+ segments,
244
+ fsPath: fsPath.replace(/\/+/g, '/') || '/',
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Watches a pages directory for changes and invokes a callback when files change.
250
+ *
251
+ * @param pagesDir - The directory to watch
252
+ * @param onChange - Callback invoked when a page file changes
253
+ * @returns A function to stop watching
254
+ *
255
+ * @example
256
+ * ```ts
257
+ * const stop = watchPages('./src/pages', () => {
258
+ * console.log('Pages changed, regenerating routes...');
259
+ * });
260
+ *
261
+ * // Later: stop watching
262
+ * stop();
263
+ * ```
264
+ */
265
+ export function watchPages(
266
+ pagesDir: string,
267
+ onChange: () => void
268
+ ): () => void {
269
+ if (!fs.existsSync(pagesDir)) {
270
+ fs.mkdirSync(pagesDir, { recursive: true });
271
+ }
272
+
273
+ const watcher = fs.watch(pagesDir, { recursive: true }, (_eventType, filename) => {
274
+ if (!filename) return;
275
+
276
+ const ext = path.extname(filename);
277
+ if (VALID_EXTENSIONS.includes(ext)) {
278
+ onChange();
279
+ }
280
+ });
281
+
282
+ return () => watcher.close();
283
+ }