aplosjs 0.15.0
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 +28 -0
- package/aplos.config.dist.js +30 -0
- package/bin/aplos +60 -0
- package/create-aplos/index.js +95 -0
- package/create-aplos/package.json +29 -0
- package/create-aplos/templates/minimal/README.md +38 -0
- package/create-aplos/templates/minimal/_gitignore +7 -0
- package/create-aplos/templates/minimal/aplos.config.js +13 -0
- package/create-aplos/templates/minimal/package.json +22 -0
- package/create-aplos/templates/minimal/public/favicon.svg +4 -0
- package/create-aplos/templates/minimal/src/pages/_app.tsx +6 -0
- package/create-aplos/templates/minimal/src/pages/about.tsx +24 -0
- package/create-aplos/templates/minimal/src/pages/index.tsx +40 -0
- package/create-aplos/templates/minimal/src/styles/global.css +53 -0
- package/create-aplos/templates/minimal/tsconfig.json +18 -0
- package/package.json +92 -0
- package/postcss.config.js +9 -0
- package/rspack.config.js +306 -0
- package/rspack.ssr.config.js +129 -0
- package/src/build/config.js +42 -0
- package/src/build/css-noop-loader.cjs +3 -0
- package/src/build/router.js +609 -0
- package/src/build/ssg.js +198 -0
- package/src/client/public/index.html +8 -0
- package/src/command/build.js +105 -0
- package/src/command/create.js +91 -0
- package/src/command/devServer.js +198 -0
- package/src/command/router.js +137 -0
- package/src/components/head.jsx +65 -0
- package/src/components/navigation.jsx +11 -0
- package/src/config.js +5 -0
- package/src/pages/_app.tsx +9 -0
- package/src/pages/blog/[slug].tsx +6 -0
- package/src/pages/crash.tsx +6 -0
- package/src/pages/index.tsx +10 -0
- package/src/pages/test.tsx +5 -0
- package/src/runtime/DefaultErrorPage.jsx +76 -0
- package/src/runtime/ErrorBoundary.jsx +40 -0
- package/src/runtime/MiddlewareGate.jsx +149 -0
- package/src/runtime/app-ssr.jsx +42 -0
- package/src/runtime/app.jsx +126 -0
- package/src/runtime/default-middleware.js +10 -0
- package/src/runtime/default-not-found.jsx +3 -0
- package/src/runtime/passthrough-layout.jsx +5 -0
- package/src/runtime/redirect.js +46 -0
- package/src/runtime/ssr-entry.jsx +104 -0
- package/templates/minimal/README.md +38 -0
- package/templates/minimal/_gitignore +7 -0
- package/templates/minimal/aplos.config.js +13 -0
- package/templates/minimal/package.json +22 -0
- package/templates/minimal/public/favicon.svg +4 -0
- package/templates/minimal/src/pages/_app.tsx +6 -0
- package/templates/minimal/src/pages/about.tsx +24 -0
- package/templates/minimal/src/pages/index.tsx +40 -0
- package/templates/minimal/src/styles/global.css +53 -0
- package/templates/minimal/tsconfig.json +18 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { glob } from 'glob';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
|
|
9
|
+
export async function buildRouter(aplos) {
|
|
10
|
+
const appExtensions = ['.tsx', '.jsx', '.js'];
|
|
11
|
+
|
|
12
|
+
let projectDirectory = process.cwd();
|
|
13
|
+
const pageDirectory = path.join(projectDirectory, 'src', 'pages');
|
|
14
|
+
|
|
15
|
+
const pages = [];
|
|
16
|
+
const capitalize = s => s && s[0].toUpperCase() + s.slice(1)
|
|
17
|
+
|
|
18
|
+
if (!fs.existsSync(pageDirectory)) {
|
|
19
|
+
console.warn("No pages directory found");
|
|
20
|
+
process.exit(0);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const filenames = await getFiles(pageDirectory, appExtensions);
|
|
25
|
+
if (filenames.length === 0) {
|
|
26
|
+
console.warn('No page files found in:', pageDirectory);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Build layout tree for nested layouts
|
|
30
|
+
const layoutTree = buildLayoutTree(pageDirectory, appExtensions);
|
|
31
|
+
|
|
32
|
+
const routes = aplos.routes || [];
|
|
33
|
+
|
|
34
|
+
const generateComponentName = (nameParts, fileName) => {
|
|
35
|
+
const parts = nameParts.filter(part => part && part !== fileName);
|
|
36
|
+
|
|
37
|
+
const processedParts = parts.map(part => {
|
|
38
|
+
if (part.startsWith('[')) {
|
|
39
|
+
const parentDir = parts[parts.indexOf(part) - 1];
|
|
40
|
+
return parentDir ?
|
|
41
|
+
capitalize(formatPath(parentDir)) + capitalize(formatPath(part)) :
|
|
42
|
+
capitalize(formatPath(part));
|
|
43
|
+
}
|
|
44
|
+
return capitalize(formatPath(part));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
processedParts.push(capitalize(formatPath(fileName)));
|
|
48
|
+
|
|
49
|
+
return processedParts.join('');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
filenames.forEach(file => {
|
|
53
|
+
let name = file.replace('~', '').replace(/\.(js|tsx|jsx)$/, '');
|
|
54
|
+
let nameParts = name.split('/');
|
|
55
|
+
let fileName = nameParts.pop();
|
|
56
|
+
|
|
57
|
+
let capitalizeName = generateComponentName(nameParts, fileName);
|
|
58
|
+
|
|
59
|
+
let path;
|
|
60
|
+
if (fileName === 'index') {
|
|
61
|
+
path = nameParts.join('/') || '/';
|
|
62
|
+
} else {
|
|
63
|
+
path = name;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let found = routes.find(element => element.source === path);
|
|
67
|
+
if (found && found.destination) {
|
|
68
|
+
path = found.destination;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
path = path.replace(/\[\.\.\..*?]/g, '*');
|
|
72
|
+
path = path.replace(/\[(.*?)]/g, ':$1');
|
|
73
|
+
|
|
74
|
+
const absolutePageFile = `${pageDirectory}${file.replace('~', '')}`;
|
|
75
|
+
const staticDirective = hasUseStaticDirective(absolutePageFile);
|
|
76
|
+
|
|
77
|
+
let existingPage = pages.find(p => p.path === path);
|
|
78
|
+
if (!existingPage) {
|
|
79
|
+
const config = {
|
|
80
|
+
"path": path,
|
|
81
|
+
"component": capitalizeName,
|
|
82
|
+
"file": file.replaceAll('//', '/'),
|
|
83
|
+
"requirement": {},
|
|
84
|
+
"static": staticDirective
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
routes.push(config);
|
|
88
|
+
pages.push(config);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Expand `paths` declared in route entries into concrete static pages that
|
|
93
|
+
// share the component of their matching catch-all. Lets the SSG pre-render
|
|
94
|
+
// each URL without asking the user to split the catch-all into many files.
|
|
95
|
+
const configuredRoutes = aplos.routes || [];
|
|
96
|
+
for (const entry of configuredRoutes) {
|
|
97
|
+
if (!entry || !entry.paths || !entry.source) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const catchAllPath = entry.source.replace(/\[\.\.\..*?]/g, '*').replace(/\[(.*?)]/g, ':$1');
|
|
101
|
+
const catchAll = pages.find(p => p.path === catchAllPath);
|
|
102
|
+
if (!catchAll) {
|
|
103
|
+
console.warn(`Route config: no page matches source "${entry.source}" — skipping paths expansion.`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
let paths = entry.paths;
|
|
107
|
+
if (typeof paths === 'function') {
|
|
108
|
+
try {
|
|
109
|
+
paths = paths();
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error(`Route config: paths() threw for source "${entry.source}":`, error.message);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!Array.isArray(paths)) {
|
|
116
|
+
console.warn(`Route config: paths for "${entry.source}" must be an array or a function returning one.`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const rawEntry of paths) {
|
|
120
|
+
let concretePath;
|
|
121
|
+
let inlineMeta = null;
|
|
122
|
+
if (typeof rawEntry === 'string') {
|
|
123
|
+
concretePath = rawEntry;
|
|
124
|
+
} else if (rawEntry && typeof rawEntry === 'object' && typeof rawEntry.path === 'string') {
|
|
125
|
+
concretePath = rawEntry.path;
|
|
126
|
+
if (rawEntry.meta && typeof rawEntry.meta === 'object') {
|
|
127
|
+
inlineMeta = rawEntry.meta;
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!concretePath.startsWith('/')) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (pages.find(p => p.path === concretePath)) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
pages.push({
|
|
139
|
+
path: concretePath,
|
|
140
|
+
component: catchAll.component,
|
|
141
|
+
file: catchAll.file,
|
|
142
|
+
requirement: {},
|
|
143
|
+
static: true,
|
|
144
|
+
sourcePath: entry.source,
|
|
145
|
+
inlineMeta,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Detect _app, _404, _error files
|
|
151
|
+
const appFile = findSpecialFile(pageDirectory, '_app', appExtensions);
|
|
152
|
+
const notFoundFile = findSpecialFile(pageDirectory, '_404', appExtensions);
|
|
153
|
+
const errorFile = findSpecialFile(pageDirectory, '_error', appExtensions);
|
|
154
|
+
|
|
155
|
+
// Detect the optional route middleware at src/middleware.{ts,tsx,js,jsx}.
|
|
156
|
+
// Lives next to src/pages (project-level), not inside it, so it never
|
|
157
|
+
// becomes a route. Resolved at runtime via the @aplos_middleware alias.
|
|
158
|
+
const srcDirectory = path.join(projectDirectory, 'src');
|
|
159
|
+
const middlewareFile = findSpecialFile(srcDirectory, 'middleware', appExtensions);
|
|
160
|
+
|
|
161
|
+
// Generate pages.js (re-exports for all pages + special files). Pages
|
|
162
|
+
// expanded from `routes[].paths` reuse the same component as their
|
|
163
|
+
// catch-all parent, so we dedupe exports by component name here.
|
|
164
|
+
const pagesExports = [];
|
|
165
|
+
const exportedComponents = new Set();
|
|
166
|
+
pages.forEach(page => {
|
|
167
|
+
if (exportedComponents.has(page.component)) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
exportedComponents.add(page.component);
|
|
171
|
+
const componentFileName = page.file.replace('~', '');
|
|
172
|
+
pagesExports.push(`export { default as ${page.component} } from "${projectDirectory}/src/pages${componentFileName}";`);
|
|
173
|
+
// Re-export the optional `meta` named export under a deterministic alias.
|
|
174
|
+
// Only emit the namespace import when the source file actually exports
|
|
175
|
+
// `meta` — otherwise rspack/webpack fires an ESModulesLinkingWarning
|
|
176
|
+
// per page that doesn't opt in, drowning real warnings on large sites.
|
|
177
|
+
const absolutePageFile = `${pageDirectory}${componentFileName}`;
|
|
178
|
+
if (pageHasMetaExport(absolutePageFile)) {
|
|
179
|
+
pagesExports.push(`import * as ${page.component}__module from "${projectDirectory}/src/pages${componentFileName}";`);
|
|
180
|
+
pagesExports.push(`export const ${page.component}__meta = ${page.component}__module.meta || null;`);
|
|
181
|
+
} else {
|
|
182
|
+
pagesExports.push(`export const ${page.component}__meta = null;`);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Layout exports
|
|
187
|
+
layoutTree.forEach(layout => {
|
|
188
|
+
pagesExports.push(`export { default as ${layout.component} } from "${projectDirectory}/src/pages/${layout.file}";`);
|
|
189
|
+
pagesExports.push(`export const ${layout.component}__meta = null;`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// AppLayout
|
|
193
|
+
if (appFile) {
|
|
194
|
+
pagesExports.push(`export { default as AppLayout } from "${projectDirectory}/src/pages/${appFile}";`);
|
|
195
|
+
} else {
|
|
196
|
+
pagesExports.push(`export { default as AppLayout } from "aplos/internal/passthrough-layout";`);
|
|
197
|
+
}
|
|
198
|
+
pagesExports.push(`export const AppLayout__meta = null;`);
|
|
199
|
+
|
|
200
|
+
// NoMatch (404)
|
|
201
|
+
if (notFoundFile) {
|
|
202
|
+
pagesExports.push(`export { default as NoMatch } from "${projectDirectory}/src/pages/${notFoundFile}";`);
|
|
203
|
+
} else {
|
|
204
|
+
pagesExports.push(`export { default as NoMatch } from "aplos/internal/default-not-found";`);
|
|
205
|
+
}
|
|
206
|
+
pagesExports.push(`export const NoMatch__meta = null;`);
|
|
207
|
+
|
|
208
|
+
// CustomError (optional)
|
|
209
|
+
if (errorFile) {
|
|
210
|
+
pagesExports.push(`export { default as CustomError } from "${projectDirectory}/src/pages/${errorFile}";`);
|
|
211
|
+
} else {
|
|
212
|
+
pagesExports.push(`export const CustomError = null;`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Build nested route tree as JS data
|
|
216
|
+
const nestedRoutes = buildNestedRoutes(pages, layoutTree);
|
|
217
|
+
|
|
218
|
+
// Generate routes.js
|
|
219
|
+
const routesFileContent = generateRoutesFile(nestedRoutes);
|
|
220
|
+
|
|
221
|
+
// Generate head.js
|
|
222
|
+
const headFileContent = generateHeadFile(aplos.head || {}, aplos.reactStrictMode);
|
|
223
|
+
|
|
224
|
+
// Generate middleware.js — re-export the project middleware as default, or
|
|
225
|
+
// fall back to the framework no-op so the runtime import always resolves.
|
|
226
|
+
const middlewareFileContent = middlewareFile
|
|
227
|
+
? `export { default } from "${projectDirectory}/src/${middlewareFile}";\n`
|
|
228
|
+
: `export { default } from "aplos/internal/default-middleware";\n`;
|
|
229
|
+
|
|
230
|
+
// Write each cache file only when its content changed (writeIfChanged)
|
|
231
|
+
// so an unchanged router cache keeps a stable mtime and doesn't trigger
|
|
232
|
+
// a full reload.
|
|
233
|
+
try {
|
|
234
|
+
const cacheDir = path.join(projectDirectory, '.aplos', 'cache');
|
|
235
|
+
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
236
|
+
|
|
237
|
+
await writeIfChanged(
|
|
238
|
+
path.join(cacheDir, 'router.js'),
|
|
239
|
+
JSON.stringify(routes)
|
|
240
|
+
);
|
|
241
|
+
await writeIfChanged(
|
|
242
|
+
path.join(cacheDir, 'pages.js'),
|
|
243
|
+
pagesExports.join('\n') + '\n'
|
|
244
|
+
);
|
|
245
|
+
await writeIfChanged(
|
|
246
|
+
path.join(cacheDir, 'routes.js'),
|
|
247
|
+
routesFileContent
|
|
248
|
+
);
|
|
249
|
+
await writeIfChanged(
|
|
250
|
+
path.join(cacheDir, 'head.js'),
|
|
251
|
+
headFileContent
|
|
252
|
+
);
|
|
253
|
+
await writeIfChanged(
|
|
254
|
+
path.join(cacheDir, 'middleware.js'),
|
|
255
|
+
middlewareFileContent
|
|
256
|
+
);
|
|
257
|
+
await writeIfChanged(
|
|
258
|
+
path.join(cacheDir, 'config.js'),
|
|
259
|
+
`export default ${JSON.stringify(aplos, null, 2)};`
|
|
260
|
+
);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
console.error('Failed to write cache files:', error.message);
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Write `content` to `filePath` only when it differs from disk, keeping the
|
|
269
|
+
* mtime stable when nothing changed.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} filePath
|
|
272
|
+
* @param {string} content
|
|
273
|
+
* @returns {Promise<void>}
|
|
274
|
+
*/
|
|
275
|
+
async function writeIfChanged(filePath, content) {
|
|
276
|
+
try {
|
|
277
|
+
const existing = await fs.promises.readFile(filePath, 'utf-8');
|
|
278
|
+
if (existing === content) {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (error.code !== 'ENOENT') {
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
await fs.promises.writeFile(filePath, content);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Find a special file (_app, _404, _error) in the pages directory
|
|
291
|
+
*/
|
|
292
|
+
function findSpecialFile(pageDirectory, baseName, extensions) {
|
|
293
|
+
return extensions
|
|
294
|
+
.map(ext => `${baseName}${ext}`)
|
|
295
|
+
.find(file => {
|
|
296
|
+
try {
|
|
297
|
+
return fs.existsSync(path.join(pageDirectory, file));
|
|
298
|
+
} catch (error) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}) || null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Generate routes.js content — pure JS, no JSX
|
|
306
|
+
*/
|
|
307
|
+
function generateRoutesFile(routeTree) {
|
|
308
|
+
const names = collectComponentNames(routeTree);
|
|
309
|
+
const metaNames = names.map(n => `${n}__meta`);
|
|
310
|
+
const lines = [];
|
|
311
|
+
lines.push(`import { ${[...names, ...metaNames].join(', ')} } from './pages.js';`);
|
|
312
|
+
lines.push('');
|
|
313
|
+
lines.push(`export const routeTree = ${serializeRouteTree(routeTree)};`);
|
|
314
|
+
return lines.join('\n') + '\n';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Collect all component names referenced in the route tree
|
|
319
|
+
*/
|
|
320
|
+
function collectComponentNames(nodes) {
|
|
321
|
+
const names = new Set();
|
|
322
|
+
function walk(nodes) {
|
|
323
|
+
for (const node of nodes) {
|
|
324
|
+
if (node.component) names.add(node.component);
|
|
325
|
+
if (node.children) walk(node.children);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
walk(nodes);
|
|
329
|
+
return Array.from(names);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Serialize route tree to JS source (references to imported components, not strings)
|
|
334
|
+
*/
|
|
335
|
+
function serializeRouteTree(nodes, indent = '') {
|
|
336
|
+
const inner = indent + ' ';
|
|
337
|
+
const items = nodes.map(node => {
|
|
338
|
+
const parts = [];
|
|
339
|
+
if (node.component) {
|
|
340
|
+
parts.push(`${inner}element: ${node.component}`);
|
|
341
|
+
if (node.inlineMeta) {
|
|
342
|
+
parts.push(`${inner}meta: ${JSON.stringify(node.inlineMeta)}`);
|
|
343
|
+
} else {
|
|
344
|
+
parts.push(`${inner}meta: ${node.component}__meta`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (node.path !== undefined) parts.push(`${inner}path: ${JSON.stringify(node.path)}`);
|
|
348
|
+
if (node.sourcePath) parts.push(`${inner}sourcePath: ${JSON.stringify(node.sourcePath)}`);
|
|
349
|
+
if (node.static === true) parts.push(`${inner}static: true`);
|
|
350
|
+
if (node.children) {
|
|
351
|
+
parts.push(`${inner}children: ${serializeRouteTree(node.children, inner)}`);
|
|
352
|
+
}
|
|
353
|
+
return `${indent}{\n${parts.join(',\n')}\n${indent}}`;
|
|
354
|
+
});
|
|
355
|
+
return `[\n${items.join(',\n')}\n${indent}]`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Generate head.js content — pure JS
|
|
360
|
+
*/
|
|
361
|
+
function generateHeadFile(head, reactStrictMode) {
|
|
362
|
+
const { defaultTitle, titleTemplate, meta = [], link = [], script = [] } = head;
|
|
363
|
+
const headObj = {};
|
|
364
|
+
if (defaultTitle) headObj.defaultTitle = defaultTitle;
|
|
365
|
+
if (titleTemplate) headObj.titleTemplate = titleTemplate;
|
|
366
|
+
if (meta.length > 0) headObj.meta = meta;
|
|
367
|
+
if (link.length > 0) headObj.link = link;
|
|
368
|
+
if (script.length > 0) headObj.script = script;
|
|
369
|
+
|
|
370
|
+
const lines = [];
|
|
371
|
+
lines.push(`export default ${JSON.stringify(headObj, null, 2)};`);
|
|
372
|
+
lines.push(`export const reactStrictMode = ${!!reactStrictMode};`);
|
|
373
|
+
return lines.join('\n') + '\n';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Detect whether a page module exports a `meta` named binding. Used by the
|
|
378
|
+
* router generator to decide whether to emit the `import * as ...__module`
|
|
379
|
+
* re-export pair (rspack warns on namespace access for missing named exports).
|
|
380
|
+
*
|
|
381
|
+
* We use a deliberately permissive regex: false positives only emit the same
|
|
382
|
+
* code we used to emit unconditionally — no harm done. The patterns covered:
|
|
383
|
+
* export const meta
|
|
384
|
+
* export let meta
|
|
385
|
+
* export var meta
|
|
386
|
+
* export function meta
|
|
387
|
+
* export async function meta
|
|
388
|
+
* export { meta } (also { meta as ... } / { foo as meta })
|
|
389
|
+
*
|
|
390
|
+
* Exports inside line/block comments are filtered out before scanning so
|
|
391
|
+
* commented-out examples in the source don't trigger a match.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} filePath
|
|
394
|
+
* @returns {boolean}
|
|
395
|
+
*/
|
|
396
|
+
function pageHasMetaExport(filePath) {
|
|
397
|
+
let source;
|
|
398
|
+
try {
|
|
399
|
+
source = fs.readFileSync(filePath, 'utf-8');
|
|
400
|
+
} catch (error) {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
// Strip block and line comments to avoid matching commented-out exports.
|
|
404
|
+
const stripped = source
|
|
405
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
406
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
407
|
+
if (/export\s+(?:const|let|var|function|async\s+function)\s+meta\b/.test(stripped)) {
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
if (/export\s*\{[^}]*\bmeta\b[^}]*\}/.test(stripped)) {
|
|
411
|
+
return true;
|
|
412
|
+
}
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Detect a leading `'use static'` / `"use static"` directive in a page file.
|
|
418
|
+
* Scans the first non-empty, non-comment lines before any code/import.
|
|
419
|
+
* @param {string} filePath
|
|
420
|
+
* @returns {boolean}
|
|
421
|
+
*/
|
|
422
|
+
function hasUseStaticDirective(filePath) {
|
|
423
|
+
try {
|
|
424
|
+
const source = fs.readFileSync(filePath, 'utf-8');
|
|
425
|
+
const lines = source.split('\n');
|
|
426
|
+
for (const rawLine of lines) {
|
|
427
|
+
const line = rawLine.trim();
|
|
428
|
+
if (line === '') {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) {
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (line === `'use static';` || line === `"use static";` || line === `'use static'` || line === `"use static"`) {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
} catch (error) {
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
*
|
|
447
|
+
* @param {string} dirPath
|
|
448
|
+
* @param {string[]} extensions
|
|
449
|
+
* @returns {Promise<string[]>}
|
|
450
|
+
*/
|
|
451
|
+
export async function getFiles(dirPath, extensions) {
|
|
452
|
+
const patterns = extensions.map(ext => `**/*${ext}`);
|
|
453
|
+
const globPattern = patterns.length === 1 ? patterns[0] : `{${patterns.join(',')}}`;
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const files = await glob(globPattern, {
|
|
457
|
+
cwd: dirPath,
|
|
458
|
+
ignore: ['**/_*'] // ignore files starting with _
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
return files.map(file => '/' + file);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
console.error(`Error globbing files in ${dirPath}:`, error);
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
*
|
|
470
|
+
* @param {string} path
|
|
471
|
+
* @returns {string}
|
|
472
|
+
*/
|
|
473
|
+
export function formatPath(path) {
|
|
474
|
+
return path.replace(/\.\.\./g, '').replace(/[\[\]_-]/g, '');
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Build nested layout tree by scanning for _layout files
|
|
479
|
+
* @param {string} pageDirectory
|
|
480
|
+
* @param {string[]} extensions
|
|
481
|
+
* @returns {Map<string, object>}
|
|
482
|
+
*/
|
|
483
|
+
function buildLayoutTree(pageDirectory, extensions) {
|
|
484
|
+
const layouts = new Map();
|
|
485
|
+
|
|
486
|
+
function generateLayoutName(pathPrefix) {
|
|
487
|
+
if (!pathPrefix || pathPrefix === '/') return 'RootLayout';
|
|
488
|
+
return pathPrefix.split('/').filter(Boolean).map(part =>
|
|
489
|
+
part.charAt(0).toUpperCase() + formatPath(part.slice(1))
|
|
490
|
+
).join('') + 'Layout';
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function scanLayouts(dir, pathPrefix = '') {
|
|
494
|
+
const layoutFile = extensions
|
|
495
|
+
.map(ext => `_layout${ext}`)
|
|
496
|
+
.find(file => {
|
|
497
|
+
try {
|
|
498
|
+
return fs.existsSync(path.join(dir, file));
|
|
499
|
+
} catch (error) {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
if (layoutFile) {
|
|
505
|
+
const layoutPath = pathPrefix ? path.join(pathPrefix, layoutFile) : layoutFile;
|
|
506
|
+
layouts.set(pathPrefix || '/', {
|
|
507
|
+
file: layoutPath,
|
|
508
|
+
component: generateLayoutName(pathPrefix),
|
|
509
|
+
path: pathPrefix || '/'
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Scan subdirectories recursively
|
|
514
|
+
try {
|
|
515
|
+
const subdirs = fs.readdirSync(dir, { withFileTypes: true })
|
|
516
|
+
.filter(dirent => dirent.isDirectory() && !dirent.name.startsWith('_'))
|
|
517
|
+
.map(dirent => dirent.name);
|
|
518
|
+
|
|
519
|
+
subdirs.forEach(subdir => {
|
|
520
|
+
const newPathPrefix = pathPrefix ? path.join(pathPrefix, subdir) : subdir;
|
|
521
|
+
scanLayouts(
|
|
522
|
+
path.join(dir, subdir),
|
|
523
|
+
newPathPrefix
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
} catch (error) {
|
|
527
|
+
// Ignore directories we can't read
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
scanLayouts(pageDirectory);
|
|
532
|
+
return layouts;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Build nested route structure with layouts
|
|
537
|
+
* Returns a JS array (data) instead of JSX strings
|
|
538
|
+
* @param {Array} pages
|
|
539
|
+
* @param {Map} layoutTree
|
|
540
|
+
* @returns {Array}
|
|
541
|
+
*/
|
|
542
|
+
function buildNestedRoutes(pages, layoutTree) {
|
|
543
|
+
// Group pages by their directory path
|
|
544
|
+
const routesByPath = new Map();
|
|
545
|
+
|
|
546
|
+
pages.forEach(page => {
|
|
547
|
+
const pagePath = page.path;
|
|
548
|
+
const segments = pagePath.split('/').filter(Boolean);
|
|
549
|
+
|
|
550
|
+
// Find the most specific layout for this page
|
|
551
|
+
let layoutPath = '/';
|
|
552
|
+
for (let i = segments.length; i > 0; i--) {
|
|
553
|
+
const testPath = '/' + segments.slice(0, i).join('/');
|
|
554
|
+
if (layoutTree.has(testPath)) {
|
|
555
|
+
layoutPath = testPath;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (!routesByPath.has(layoutPath)) {
|
|
561
|
+
routesByPath.set(layoutPath, []);
|
|
562
|
+
}
|
|
563
|
+
routesByPath.get(layoutPath).push(page);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
function buildRouteLevel(currentPath = '/') {
|
|
567
|
+
const nodes = [];
|
|
568
|
+
|
|
569
|
+
// Add pages for this level
|
|
570
|
+
const pagesAtLevel = routesByPath.get(currentPath) || [];
|
|
571
|
+
pagesAtLevel.forEach(page => {
|
|
572
|
+
const node = { path: page.path, component: page.component };
|
|
573
|
+
if (page.static) {
|
|
574
|
+
node.static = true;
|
|
575
|
+
}
|
|
576
|
+
if (page.sourcePath) {
|
|
577
|
+
node.sourcePath = page.sourcePath;
|
|
578
|
+
}
|
|
579
|
+
if (page.inlineMeta) {
|
|
580
|
+
node.inlineMeta = page.inlineMeta;
|
|
581
|
+
}
|
|
582
|
+
nodes.push(node);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// Add nested layouts
|
|
586
|
+
Array.from(layoutTree.keys())
|
|
587
|
+
.filter(layoutPath => layoutPath.startsWith(currentPath) && layoutPath !== currentPath)
|
|
588
|
+
.forEach(layoutPath => {
|
|
589
|
+
const layout = layoutTree.get(layoutPath);
|
|
590
|
+
nodes.push({
|
|
591
|
+
component: layout.component,
|
|
592
|
+
children: buildRouteLevel(layoutPath)
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
return nodes;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
let innerRoutes = buildRouteLevel();
|
|
600
|
+
|
|
601
|
+
// Wrap with root layout if exists
|
|
602
|
+
if (layoutTree.has('/')) {
|
|
603
|
+
const rootLayout = layoutTree.get('/');
|
|
604
|
+
innerRoutes = [{ component: rootLayout.component, children: innerRoutes }];
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Wrap everything with AppLayout
|
|
608
|
+
return [{ component: 'AppLayout', children: innerRoutes }];
|
|
609
|
+
}
|