webspresso 0.0.101 → 0.0.102
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/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +99 -2
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +6 -0
- package/README.md +18 -5
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/routes.js +8 -0
- package/core/applySchema.js +18 -1
- package/index.d.ts +156 -1
- package/index.js +71 -16
- package/package.json +5 -5
- package/plugins/admin-panel/index.js +12 -0
- package/plugins/csrf/index.js +1 -1
- package/plugins/file-manager/index.js +13 -0
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- package/src/api/api-loader.js +17 -1
- package/src/api/validation-i18n.js +231 -0
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +70 -4
- package/src/file-router.js +153 -91
- package/src/njk-frontmatter.js +17 -1
- package/src/plugin-manager.js +12 -0
- package/src/routing/route-table.js +1 -1
- package/src/server.js +63 -8
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/template/precompile.js +247 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Webspresso - Ahead-Of-Time (AOT) Route Manifest
|
|
5
|
+
* Generates and loads portable, pre-compiled route manifests to eliminate filesystem
|
|
6
|
+
* scanning and speed up cold starts in production and containerized environments.
|
|
7
|
+
* @module src/discovery/route-manifest
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { scanRoutes } = require('./scan-routes');
|
|
13
|
+
const { isApiRouteFile } = require('./file-route-parser');
|
|
14
|
+
const { filePathToRoute, extractMethodFromFilename } = require('../file-router');
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MANIFEST_REL_PATH = path.join('.webspresso', 'routes.manifest.json');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Generates an Ahead-Of-Time (AOT) route manifest and writes it to disk.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} [options={}]
|
|
22
|
+
* @param {string} [options.rootDir=process.cwd()] - Project root directory
|
|
23
|
+
* @param {string|Object} [options.pages] - Pages directory configuration or path
|
|
24
|
+
* @param {string|Object} [options.api] - API directory configuration or path
|
|
25
|
+
* @param {string|Object} [options.modules] - Modules directory configuration or path
|
|
26
|
+
* @param {string} [options.outDir] - Directory where manifest will be saved
|
|
27
|
+
* @param {string} [options.outFile] - Custom output file path for the manifest
|
|
28
|
+
* @param {boolean} [options.silent=false] - Suppress console output
|
|
29
|
+
* @returns {{ manifest: Object, manifestPath: string, routeCount: number, scannedFilesCount: number }}
|
|
30
|
+
*/
|
|
31
|
+
function generateRouteManifest(options = {}) {
|
|
32
|
+
const rootDir = options.rootDir ? path.resolve(options.rootDir) : process.cwd();
|
|
33
|
+
const pagesDir = options.pagesDir
|
|
34
|
+
? path.resolve(options.pagesDir)
|
|
35
|
+
: (typeof options.pages === 'string' ? path.resolve(options.pages) : path.join(rootDir, 'pages'));
|
|
36
|
+
|
|
37
|
+
const descriptors = scanRoutes({
|
|
38
|
+
rootDir,
|
|
39
|
+
pages: options.pages !== undefined ? options.pages : (fs.existsSync(pagesDir) ? { dir: path.relative(rootDir, pagesDir) || 'pages' } : true),
|
|
40
|
+
api: options.api,
|
|
41
|
+
modules: options.modules,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const routes = [];
|
|
45
|
+
const routeKeySet = new Set();
|
|
46
|
+
|
|
47
|
+
// 1. Discovered routes from scanRoutes (src/pages, src/api, modules/*)
|
|
48
|
+
for (const d of descriptors) {
|
|
49
|
+
const method = (d.method || 'GET').toUpperCase();
|
|
50
|
+
const key = `${method}:${d.path}`;
|
|
51
|
+
routeKeySet.add(key);
|
|
52
|
+
routes.push({
|
|
53
|
+
type: d.type,
|
|
54
|
+
method,
|
|
55
|
+
path: d.path,
|
|
56
|
+
source: d.source || (d.file ? path.relative(rootDir, d.file).split(path.sep).join('/') : ''),
|
|
57
|
+
module: d.module || null,
|
|
58
|
+
moduleConfig: d.moduleConfig ? { name: d.moduleConfig.name } : null,
|
|
59
|
+
target: 'discovered',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. File-based routes from pagesDir (pages/api/**/*.js and pages/**/*.njk)
|
|
64
|
+
const pagesRel = path.relative(rootDir, pagesDir).split(path.sep).join('/') || 'pages';
|
|
65
|
+
const scannedPagesFiles = descriptors.scannedPagesFiles || [];
|
|
66
|
+
|
|
67
|
+
for (const relFile of scannedPagesFiles) {
|
|
68
|
+
const ext = path.extname(relFile);
|
|
69
|
+
if (isApiRouteFile(relFile) && ext === '.js') {
|
|
70
|
+
const { method: rawMethod, baseName } = extractMethodFromFilename(path.basename(relFile));
|
|
71
|
+
const method = (rawMethod || 'get').toUpperCase();
|
|
72
|
+
const dirPart = path.dirname(relFile);
|
|
73
|
+
const rawRoutePath = dirPart === '.'
|
|
74
|
+
? `/${baseName}`
|
|
75
|
+
: `/${dirPart}/${baseName}`.split(path.sep).join('/');
|
|
76
|
+
const routePath = filePathToRoute(rawRoutePath, '');
|
|
77
|
+
const key = `${method}:${routePath}`;
|
|
78
|
+
if (!routeKeySet.has(key)) {
|
|
79
|
+
routeKeySet.add(key);
|
|
80
|
+
routes.push({
|
|
81
|
+
type: 'api',
|
|
82
|
+
method,
|
|
83
|
+
path: routePath,
|
|
84
|
+
source: path.join(pagesRel, relFile).split(path.sep).join('/'),
|
|
85
|
+
module: null,
|
|
86
|
+
moduleConfig: null,
|
|
87
|
+
target: 'pages',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
} else if (ext === '.njk') {
|
|
91
|
+
const routePath = filePathToRoute(relFile, '.njk');
|
|
92
|
+
const key = `GET:${routePath}`;
|
|
93
|
+
if (!routeKeySet.has(key)) {
|
|
94
|
+
routeKeySet.add(key);
|
|
95
|
+
routes.push({
|
|
96
|
+
type: 'page',
|
|
97
|
+
method: 'GET',
|
|
98
|
+
path: routePath,
|
|
99
|
+
source: path.join(pagesRel, relFile).split(path.sep).join('/'),
|
|
100
|
+
module: null,
|
|
101
|
+
moduleConfig: null,
|
|
102
|
+
target: 'pages',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const manifest = {
|
|
109
|
+
version: 1,
|
|
110
|
+
generatedAt: new Date().toISOString(),
|
|
111
|
+
pagesDirRel: pagesRel,
|
|
112
|
+
scannedPagesFiles,
|
|
113
|
+
routes,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const outFile = options.outFile
|
|
117
|
+
? path.resolve(options.outFile)
|
|
118
|
+
: (options.outDir
|
|
119
|
+
? path.join(path.resolve(options.outDir), 'routes.manifest.json')
|
|
120
|
+
: path.join(rootDir, DEFAULT_MANIFEST_REL_PATH));
|
|
121
|
+
|
|
122
|
+
const outDir = path.dirname(outFile);
|
|
123
|
+
if (!fs.existsSync(outDir)) {
|
|
124
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fs.writeFileSync(outFile, JSON.stringify(manifest, null, 2), 'utf8');
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
manifest,
|
|
131
|
+
manifestPath: outFile,
|
|
132
|
+
routeCount: routes.length,
|
|
133
|
+
scannedFilesCount: scannedPagesFiles.length,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Loads an existing AOT route manifest from disk and reconstructs runtime descriptors.
|
|
139
|
+
*
|
|
140
|
+
* @param {Object} [options={}]
|
|
141
|
+
* @param {string} [options.rootDir=process.cwd()] - Project root directory
|
|
142
|
+
* @param {string} [options.manifestPath] - Explicit path to the manifest file
|
|
143
|
+
* @returns {{ manifest: Object, descriptors: Array<Object> }|null}
|
|
144
|
+
*/
|
|
145
|
+
function loadRouteManifest(options = {}) {
|
|
146
|
+
const opts = typeof options === 'string' ? { manifestPath: options } : options;
|
|
147
|
+
const rootDir = opts.rootDir ? path.resolve(opts.rootDir) : process.cwd();
|
|
148
|
+
const manifestPath = opts.manifestPath
|
|
149
|
+
? path.resolve(opts.manifestPath)
|
|
150
|
+
: path.join(rootDir, DEFAULT_MANIFEST_REL_PATH);
|
|
151
|
+
|
|
152
|
+
if (!fs.existsSync(manifestPath)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
158
|
+
const manifest = JSON.parse(raw);
|
|
159
|
+
|
|
160
|
+
if (!manifest || !Array.isArray(manifest.routes)) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const descriptors = [];
|
|
165
|
+
for (const r of manifest.routes) {
|
|
166
|
+
if (r.target === 'discovered') {
|
|
167
|
+
const absFile = path.resolve(rootDir, r.source);
|
|
168
|
+
descriptors.push({
|
|
169
|
+
type: r.type,
|
|
170
|
+
method: r.method,
|
|
171
|
+
path: r.path,
|
|
172
|
+
file: absFile,
|
|
173
|
+
source: r.source,
|
|
174
|
+
module: r.module || undefined,
|
|
175
|
+
moduleConfig: r.moduleConfig || undefined,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const pagesDirRel = manifest.pagesDirRel || 'pages';
|
|
181
|
+
descriptors.scannedPagesDir = path.resolve(rootDir, pagesDirRel);
|
|
182
|
+
descriptors.scannedPagesFiles = Array.isArray(manifest.scannedPagesFiles) ? manifest.scannedPagesFiles : [];
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
manifest,
|
|
186
|
+
descriptors,
|
|
187
|
+
};
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = {
|
|
194
|
+
DEFAULT_MANIFEST_REL_PATH,
|
|
195
|
+
generateRouteManifest,
|
|
196
|
+
loadRouteManifest,
|
|
197
|
+
};
|
|
@@ -13,28 +13,46 @@ const { discoverModuleDetails } = require('../modules/module-discovery');
|
|
|
13
13
|
|
|
14
14
|
const SUPPORTED_EXTENSIONS = new Set(['.js', '.njk']);
|
|
15
15
|
|
|
16
|
+
function getGenericSegment(name) {
|
|
17
|
+
if (name.startsWith('[') && name.endsWith(']')) {
|
|
18
|
+
const inner = name.slice(1, -1);
|
|
19
|
+
if (inner.startsWith('...')) return '*';
|
|
20
|
+
return ':param';
|
|
21
|
+
}
|
|
22
|
+
return name;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
/**
|
|
17
26
|
* Safely scans a directory recursively for route files
|
|
18
27
|
* @param {string} dirPath - Absolute directory path
|
|
19
28
|
* @param {string} [baseDir=dirPath] - Base directory for computing relative paths
|
|
29
|
+
* @param {boolean} [checkWarnings=false] - Whether to perform route collision & case-sensitivity checks
|
|
20
30
|
* @returns {Array<{ absolutePath: string, relativePath: string }>}
|
|
21
31
|
*/
|
|
22
|
-
function scanDirSafely(dirPath, baseDir = dirPath) {
|
|
32
|
+
function scanDirSafely(dirPath, baseDir = dirPath, checkWarnings = false) {
|
|
23
33
|
const results = [];
|
|
24
34
|
if (!fs.existsSync(dirPath)) return results;
|
|
25
35
|
|
|
26
36
|
try {
|
|
27
37
|
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
|
38
|
+
const kept = checkWarnings ? [] : null;
|
|
28
39
|
|
|
29
40
|
for (const entry of entries) {
|
|
30
41
|
const name = entry.name;
|
|
31
|
-
if (isPrivateOrIgnored(name)) continue;
|
|
42
|
+
if (name === 'locales' || isPrivateOrIgnored(name)) continue;
|
|
32
43
|
|
|
33
44
|
const fullPath = path.join(dirPath, name);
|
|
34
45
|
const relPath = path.relative(baseDir, fullPath);
|
|
35
46
|
|
|
47
|
+
if (checkWarnings && name !== name.toLowerCase()) {
|
|
48
|
+
console.warn(`[webspresso] WARNING: Path "pages/${relPath.split(path.sep).join('/')}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
36
51
|
if (entry.isDirectory()) {
|
|
37
|
-
results.push(...scanDirSafely(fullPath, baseDir));
|
|
52
|
+
results.push(...scanDirSafely(fullPath, baseDir, checkWarnings));
|
|
53
|
+
if (checkWarnings && !relPath.startsWith('api') && relPath !== 'api') {
|
|
54
|
+
kept.push({ name, isDir: true, original: name });
|
|
55
|
+
}
|
|
38
56
|
} else if (entry.isFile()) {
|
|
39
57
|
const ext = path.extname(name);
|
|
40
58
|
if (SUPPORTED_EXTENSIONS.has(ext)) {
|
|
@@ -42,6 +60,30 @@ function scanDirSafely(dirPath, baseDir = dirPath) {
|
|
|
42
60
|
absolutePath: fullPath,
|
|
43
61
|
relativePath: relPath,
|
|
44
62
|
});
|
|
63
|
+
if (checkWarnings) {
|
|
64
|
+
const base = path.basename(name, ext);
|
|
65
|
+
kept.push({ name: base, isDir: false, original: name });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (checkWarnings && kept && kept.length > 1) {
|
|
72
|
+
const groups = new Map();
|
|
73
|
+
for (const item of kept) {
|
|
74
|
+
const normalized = getGenericSegment(item.name);
|
|
75
|
+
if (!groups.has(normalized)) {
|
|
76
|
+
groups.set(normalized, []);
|
|
77
|
+
}
|
|
78
|
+
groups.get(normalized).push(item);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const [norm, items] of groups.entries()) {
|
|
82
|
+
if (items.length > 1) {
|
|
83
|
+
const namesList = items.map((item) => `"${item.original}"`).join(' and ');
|
|
84
|
+
const relDir = path.relative(baseDir, dirPath);
|
|
85
|
+
const dirLabel = relDir ? `pages/${relDir.split(path.sep).join('/')}` : 'pages';
|
|
86
|
+
console.warn(`[webspresso] WARNING: Dynamic route collision in "${dirLabel}": ${namesList} resolve to the same segment "${norm}" and will conflict at runtime.`);
|
|
45
87
|
}
|
|
46
88
|
}
|
|
47
89
|
}
|
|
@@ -93,9 +135,12 @@ function scanRoutes(options = {}) {
|
|
|
93
135
|
: resolveDirCandidate(['src/pages', 'pages']);
|
|
94
136
|
|
|
95
137
|
if (pagesDir && fs.existsSync(pagesDir)) {
|
|
96
|
-
const files = scanDirSafely(pagesDir);
|
|
138
|
+
const files = scanDirSafely(pagesDir, pagesDir, true);
|
|
97
139
|
const filesSet = new Set(files.map((f) => f.absolutePath));
|
|
98
140
|
|
|
141
|
+
descriptors.scannedPagesDir = path.resolve(pagesDir);
|
|
142
|
+
descriptors.scannedPagesFiles = files.map((f) => f.relativePath);
|
|
143
|
+
|
|
99
144
|
for (const { absolutePath, relativePath } of files) {
|
|
100
145
|
// Skip .njk templates (handled by classic Nunjucks mountPages)
|
|
101
146
|
if (relativePath.endsWith('.njk')) {
|
|
@@ -268,7 +313,28 @@ function scanRoutes(options = {}) {
|
|
|
268
313
|
return descriptors;
|
|
269
314
|
}
|
|
270
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Unified discovery of application routes (pages, api, modules) and compilation into a RouteTable
|
|
318
|
+
* @param {Object} options
|
|
319
|
+
* @returns {{ descriptors: Array<Object>, routeTable: import('../routing/route-table').RouteTable, scannedPagesFiles: Array<string>, scannedPagesDir: string|null }}
|
|
320
|
+
*/
|
|
321
|
+
function discoverAppRoutes(options = {}) {
|
|
322
|
+
const { compileRouteTable } = require('../routing/route-table');
|
|
323
|
+
const descriptors = scanRoutes(options);
|
|
324
|
+
const routeTable = compileRouteTable(descriptors, {
|
|
325
|
+
explicitRoutes: options.routes || [],
|
|
326
|
+
isDev: options.isDev,
|
|
327
|
+
});
|
|
328
|
+
return {
|
|
329
|
+
descriptors,
|
|
330
|
+
routeTable,
|
|
331
|
+
scannedPagesFiles: descriptors.scannedPagesFiles || [],
|
|
332
|
+
scannedPagesDir: descriptors.scannedPagesDir || null,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
271
336
|
module.exports = {
|
|
272
337
|
scanRoutes,
|
|
273
338
|
scanDirSafely,
|
|
339
|
+
discoverAppRoutes,
|
|
274
340
|
};
|