webspresso 0.0.98 → 0.0.99

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 (56) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/AUTH.md +169 -0
  4. package/.agents/ORM.md +88 -4
  5. package/.agents/PLUGINS.md +73 -5
  6. package/.agents/QUEUE.md +84 -0
  7. package/.agents/ROUTING.md +127 -7
  8. package/AGENTS.md +1 -0
  9. package/core/auth/index.js +60 -0
  10. package/core/auth/manager.js +134 -0
  11. package/core/auth/middleware.js +88 -0
  12. package/core/auth/oauth.js +517 -0
  13. package/core/auth/qrcode.js +499 -0
  14. package/core/auth/totp.js +504 -0
  15. package/core/orm/eager-loader.js +144 -10
  16. package/core/orm/index.js +8 -0
  17. package/core/orm/model.js +4 -0
  18. package/core/orm/query-builder.js +90 -4
  19. package/core/orm/repository.js +44 -3
  20. package/core/orm/search.js +213 -0
  21. package/core/queue/cron.js +331 -0
  22. package/core/queue/index.js +8 -1
  23. package/core/queue/manager.js +82 -1
  24. package/core/queue/scheduler.js +359 -0
  25. package/core/sse/index.js +19 -0
  26. package/core/sse/stream.js +430 -0
  27. package/index.d.ts +528 -0
  28. package/index.js +44 -0
  29. package/package.json +1 -1
  30. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  31. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  32. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  33. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  34. package/plugins/admin-panel/field-renderers/index.js +2 -0
  35. package/plugins/api-key/crypto.js +101 -0
  36. package/plugins/api-key/index.js +288 -0
  37. package/plugins/api-key/middleware.js +461 -0
  38. package/plugins/api-key/migration-template.js +48 -0
  39. package/plugins/api-key/model.js +50 -0
  40. package/plugins/api-key/scopes.js +125 -0
  41. package/plugins/audit-log/admin-component.js +487 -70
  42. package/plugins/audit-log/api-handlers.js +114 -0
  43. package/plugins/audit-log/index.js +24 -0
  44. package/plugins/file-manager/admin-component.js +20 -4
  45. package/plugins/file-manager/index.js +21 -4
  46. package/plugins/index.js +6 -0
  47. package/plugins/oauth/index.js +338 -0
  48. package/plugins/queue/index.js +4 -0
  49. package/src/api/api-loader.js +1 -0
  50. package/src/discovery/file-route-parser.js +44 -1
  51. package/src/discovery/scan-routes.js +2 -2
  52. package/src/file-router.js +137 -100
  53. package/src/helpers.js +9 -0
  54. package/src/routing/cascading.js +378 -0
  55. package/src/routing/middleware-resolver.js +92 -0
  56. package/src/server.js +6 -0
@@ -158,8 +158,11 @@ function parseFileRoute(relativePath, options = {}) {
158
158
  // Replace last segment with cleanFilename
159
159
  const routeSegments = [...segments.slice(0, -1), cleanFilename];
160
160
 
161
+ // Filter out route group segments (e.g. (marketing), (auth))
162
+ const filteredSegments = routeSegments.filter((seg) => !isRouteGroupSegment(seg));
163
+
161
164
  // Process dynamic brackets in segments
162
- const transformedSegments = routeSegments.map(transformDynamicSegment);
165
+ const transformedSegments = filteredSegments.map(transformDynamicSegment);
163
166
 
164
167
  // Handle 'index' at the end of path
165
168
  if (transformedSegments[transformedSegments.length - 1] === 'index') {
@@ -190,8 +193,48 @@ function parseFileRoute(relativePath, options = {}) {
190
193
  };
191
194
  }
192
195
 
196
+ /**
197
+ * Checks if a path segment is a route group, e.g. '(marketing)', '(auth)'
198
+ * @param {string} segment
199
+ * @returns {boolean}
200
+ */
201
+ function isRouteGroupSegment(segment) {
202
+ return (
203
+ typeof segment === 'string' &&
204
+ segment.length > 2 &&
205
+ segment.charCodeAt(0) === 40 /* '(' */ &&
206
+ segment.charCodeAt(segment.length - 1) === 41 /* ')' */
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Checks if a file path belongs to an API route, accounting for optional parent route groups.
212
+ * E.g., 'api/users.get.js' -> true
213
+ * 'api/(v1)/users.get.js' -> true
214
+ * '(marketing)/api/newsletter.post.js' -> true
215
+ * 'about.njk' -> false
216
+ * @param {string} file
217
+ * @returns {boolean}
218
+ */
219
+ function isApiRouteFile(file) {
220
+ if (!file || typeof file !== 'string') return false;
221
+ const normalized = file.split(/[/\\]/).join('/');
222
+ if (normalized.startsWith('api/')) return true;
223
+ const parts = normalized.split('/');
224
+ const apiIndex = parts.indexOf('api');
225
+ if (apiIndex > 0) {
226
+ const prefix = parts.slice(0, apiIndex);
227
+ if (prefix.every(isRouteGroupSegment)) {
228
+ return true;
229
+ }
230
+ }
231
+ return false;
232
+ }
233
+
193
234
  module.exports = {
194
235
  parseFileRoute,
236
+ isRouteGroupSegment,
237
+ isApiRouteFile,
195
238
  isPrivateOrIgnored,
196
239
  normalizePrefix,
197
240
  transformDynamicSegment,
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { parseFileRoute, isPrivateOrIgnored } = require('./file-route-parser');
11
+ const { parseFileRoute, isPrivateOrIgnored, isApiRouteFile } = require('./file-route-parser');
12
12
  const { discoverModuleDetails } = require('../modules/module-discovery');
13
13
 
14
14
  const SUPPORTED_EXTENSIONS = new Set(['.js', '.njk']);
@@ -99,7 +99,7 @@ function scanRoutes(options = {}) {
99
99
  }
100
100
 
101
101
  // Skip api directory inside pages (handled by classic mountPages or explicit api scan)
102
- const isUnderApi = relativePath.startsWith('api' + path.sep) || relativePath.startsWith('api/');
102
+ const isUnderApi = isApiRouteFile(relativePath);
103
103
  if (isUnderApi) {
104
104
  continue;
105
105
  }
@@ -17,6 +17,19 @@ const {
17
17
  registerClearHook,
18
18
  } = require('./njk-frontmatter');
19
19
  const { renderStream } = require('../core/ssr/stream');
20
+ const {
21
+ loadCascadingMiddlewares,
22
+ runMiddlewareChain,
23
+ findCascadingLayouts,
24
+ executeCascadingLayoutLoads,
25
+ applyCascadingLayouts,
26
+ templateHasExtends,
27
+ } = require('./routing/cascading');
28
+ const {
29
+ isMiddlewareFactory,
30
+ resolveNamedMiddleware,
31
+ resolveMiddlewares,
32
+ } = require('./routing/middleware-resolver');
20
33
 
21
34
  const NOOP = () => {};
22
35
  const EMPTY_OBJECT = Object.freeze({});
@@ -173,6 +186,73 @@ function escapeRegExp(s) {
173
186
  return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
174
187
  }
175
188
 
189
+ /**
190
+ * Checks if a path segment is a route group, e.g. '(marketing)', '(auth)'.
191
+ * Route groups are enclosed in parentheses and do not affect the public URL path.
192
+ * @param {string} segment
193
+ * @returns {boolean}
194
+ */
195
+ function isRouteGroupSegment(segment) {
196
+ return (
197
+ typeof segment === 'string' &&
198
+ segment.length > 2 &&
199
+ segment.charCodeAt(0) === 40 /* '(' */ &&
200
+ segment.charCodeAt(segment.length - 1) === 41 /* ')' */
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Strips route group segments from a slash-separated route path.
206
+ * E.g., '(marketing)/about' -> 'about'
207
+ * '/(auth)/login' -> '/login'
208
+ * '/(marketing)/(sub)/page' -> '/page'
209
+ * '(marketing)/index' -> 'index'
210
+ * @param {string} route
211
+ * @returns {string}
212
+ */
213
+ function stripRouteGroups(route) {
214
+ if (!route || typeof route !== 'string' || !route.includes('(')) {
215
+ return route;
216
+ }
217
+ const hadLeadingSlash = route.startsWith('/');
218
+ const rawSegments = route.split('/');
219
+ const filtered = rawSegments.filter((seg) => !isRouteGroupSegment(seg));
220
+
221
+ if (filtered.length === 0 || (filtered.length === 1 && filtered[0] === '')) {
222
+ return hadLeadingSlash ? '/' : '';
223
+ }
224
+
225
+ let res = filtered.join('/');
226
+ if (hadLeadingSlash && !res.startsWith('/')) {
227
+ res = '/' + res;
228
+ }
229
+ return res;
230
+ }
231
+
232
+ /**
233
+ * Checks if a file path belongs to an API route, accounting for optional parent route groups.
234
+ * E.g., 'api/users.get.js' -> true
235
+ * 'api/(v1)/users.get.js' -> true
236
+ * '(marketing)/api/newsletter.post.js' -> true
237
+ * 'about.njk' -> false
238
+ * @param {string} file
239
+ * @returns {boolean}
240
+ */
241
+ function isApiRouteFile(file) {
242
+ if (!file || typeof file !== 'string') return false;
243
+ const normalized = file.split(/[/\\]/).join('/');
244
+ if (normalized.startsWith('api/')) return true;
245
+ const parts = normalized.split('/');
246
+ const apiIndex = parts.indexOf('api');
247
+ if (apiIndex > 0) {
248
+ const prefix = parts.slice(0, apiIndex);
249
+ if (prefix.every(isRouteGroupSegment)) {
250
+ return true;
251
+ }
252
+ }
253
+ return false;
254
+ }
255
+
176
256
  /**
177
257
  * Convert a file path to an Express route pattern
178
258
  * @param {string} filePath - Relative path from pages/
@@ -186,11 +266,14 @@ function filePathToRoute(filePath, ext) {
186
266
  // Normalize path separators to forward slashes (handle both / and \)
187
267
  route = route.split(path.sep).join('/');
188
268
  route = route.split('\\').join('/'); // Also handle literal backslashes
269
+
270
+ // Strip route group segments (e.g. (marketing), (auth), (dashboard))
271
+ route = stripRouteGroups(route);
189
272
 
190
273
  // Handle index files
191
274
  if (route.endsWith('/index')) {
192
275
  route = route.slice(0, -6) || '/';
193
- } else if (route === 'index') {
276
+ } else if (route === 'index' || route === '') {
194
277
  route = '/';
195
278
  }
196
279
 
@@ -870,86 +953,6 @@ function detectLocale(req, supported = parseSupportedLocaleSet(), def = getDefau
870
953
  return def;
871
954
  }
872
955
 
873
- /**
874
- * True when the registry entry is (options) => (req, res, next) => …
875
- * Express handlers typically have length >= 2 (req, res) or 3 (req, res, next).
876
- */
877
- function isMiddlewareFactory(fn) {
878
- return typeof fn === 'function' && fn.length <= 1;
879
- }
880
-
881
- /**
882
- * Resolve a named middleware from createApp({ middlewares }).
883
- * @param {string} name
884
- * @param {Function} entry
885
- * @param {boolean} fromTuple - true when route used ['name', options]
886
- * @param {unknown} tupleOptions - second element of the tuple (only when fromTuple)
887
- * @param {Object} middlewareRegistry - for error messages
888
- * @returns {Function} Express middleware
889
- */
890
- function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middlewareRegistry) {
891
- if (!entry) {
892
- throw new Error(`Middleware "${name}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
893
- }
894
- if (typeof entry !== 'function') {
895
- throw new Error(`Middleware "${name}" must be a function`);
896
- }
897
-
898
- if (fromTuple) {
899
- if (!isMiddlewareFactory(entry)) {
900
- throw new Error(
901
- `Middleware "${name}" must be a factory (options) => (req, res, next) => … when using ["${name}", options] tuple form`
902
- );
903
- }
904
- const produced = entry(tupleOptions);
905
- if (typeof produced !== 'function') {
906
- throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
907
- }
908
- return produced;
909
- }
910
-
911
- if (isMiddlewareFactory(entry)) {
912
- const produced = entry({});
913
- if (typeof produced !== 'function') {
914
- throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
915
- }
916
- return produced;
917
- }
918
-
919
- return entry;
920
- }
921
-
922
- /**
923
- * Resolve middleware from config — functions, string names, or [name, options] tuples
924
- * @param {Array} middlewareConfig - middleware functions, names, or ['name', options] tuples
925
- * @param {Object} middlewareRegistry - Named middleware registry (plain handlers or option factories)
926
- * @returns {Array} Array of resolved middleware functions
927
- */
928
- function resolveMiddlewares(middlewareConfig, middlewareRegistry = EMPTY_OBJECT) {
929
- if (!middlewareConfig || !Array.isArray(middlewareConfig) || middlewareConfig.length === 0) {
930
- return EMPTY_ARRAY;
931
- }
932
-
933
- return middlewareConfig.map((mw, index) => {
934
- if (typeof mw === 'function') {
935
- return mw;
936
- }
937
-
938
- if (typeof mw === 'string') {
939
- return resolveNamedMiddleware(mw, middlewareRegistry[mw], false, undefined, middlewareRegistry);
940
- }
941
-
942
- if (Array.isArray(mw) && mw.length === 2 && typeof mw[0] === 'string') {
943
- const name = mw[0];
944
- return resolveNamedMiddleware(name, middlewareRegistry[name], true, mw[1], middlewareRegistry);
945
- }
946
-
947
- throw new Error(
948
- `Invalid middleware at index ${index}: must be a function, string name, or [name, options] tuple`
949
- );
950
- });
951
- }
952
-
953
956
  /**
954
957
  * Mount all pages as routes on the Express app
955
958
  * @param {Object} app - Express app
@@ -1073,7 +1076,7 @@ function mountPages(app, options) {
1073
1076
 
1074
1077
  for (const file of files) {
1075
1078
  const ext = path.extname(file);
1076
- const isApi = file.startsWith('api' + path.sep) || file.startsWith('api/');
1079
+ const isApi = isApiRouteFile(file);
1077
1080
 
1078
1081
  if (isApi && ext === '.js') {
1079
1082
  // API route
@@ -1209,19 +1212,32 @@ function mountPages(app, options) {
1209
1212
  throw err;
1210
1213
  }
1211
1214
 
1215
+ // Run cascading folder middlewares (_middleware.js)
1216
+ const cascadingMw = loadCascadingMiddlewares(absolutePagesDir, route.file, middlewares, isDev);
1217
+ if (cascadingMw.length) {
1218
+ await runMiddlewareChain(cascadingMw, req, res);
1219
+ if (res.headersSent) return;
1220
+ }
1221
+
1212
1222
  // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
1213
1223
  if (preResolvedMw.length) {
1214
- for (const mw of preResolvedMw) {
1215
- await new Promise((resolve, reject) => {
1216
- mw(req, res, (err) => {
1217
- if (err) reject(err);
1218
- else resolve();
1219
- });
1220
- });
1221
- }
1224
+ await runMiddlewareChain(preResolvedMw, req, res);
1225
+ if (res.headersSent) return;
1222
1226
  }
1223
-
1224
- await fn(req, res, next);
1227
+ const apiCtx = Object.assign((err) => next(err), {
1228
+ req,
1229
+ res,
1230
+ next,
1231
+ params: req.params || {},
1232
+ query: req.query || {},
1233
+ body: req.body || {},
1234
+ input: req.input,
1235
+ service: req.service,
1236
+ db,
1237
+ sse: res.sse,
1238
+ });
1239
+
1240
+ await fn(req, res, apiCtx);
1225
1241
  } catch (err) {
1226
1242
  console.error(`API error ${route.routePath}:`, err);
1227
1243
  const hookCtx = { req, res, error: err };
@@ -1354,16 +1370,17 @@ function mountPages(app, options) {
1354
1370
  hookPromise = runHooks(globalHooks, routeHooks, 'beforeMiddleware', ctx);
1355
1371
  if (hookPromise) await hookPromise;
1356
1372
 
1373
+ // Run cascading folder middlewares (_middleware.js)
1374
+ const cascadingMw = loadCascadingMiddlewares(absolutePagesDir, route.file, middlewares, isDev);
1375
+ if (cascadingMw.length) {
1376
+ await runMiddlewareChain(cascadingMw, req, res);
1377
+ if (res.headersSent) return;
1378
+ }
1379
+
1357
1380
  // Run route middleware (chain fixed at route registration; edit middleware in dev → restart)
1358
1381
  if (preResolvedPageMw.length) {
1359
- for (const mw of preResolvedPageMw) {
1360
- await new Promise((resolve, reject) => {
1361
- mw(req, res, (err) => {
1362
- if (err) reject(err);
1363
- else resolve();
1364
- });
1365
- });
1366
- }
1382
+ await runMiddlewareChain(preResolvedPageMw, req, res);
1383
+ if (res.headersSent) return;
1367
1384
  }
1368
1385
 
1369
1386
  // Execute hooks: afterMiddleware
@@ -1380,6 +1397,14 @@ function mountPages(app, options) {
1380
1397
  hookPromise = runHooks(globalHooks, routeHooks, 'beforeLoad', ctx);
1381
1398
  if (hookPromise) await hookPromise;
1382
1399
 
1400
+ // Find cascading layouts (_layout.njk)
1401
+ const cascadingLayouts = findCascadingLayouts(absolutePagesDir, route.file, isDev);
1402
+
1403
+ // Run cascading _layout.js load() functions in top-down order (outermost first)
1404
+ if (cascadingLayouts.length > 0) {
1405
+ await executeCascadingLayoutLoads(cascadingLayouts, req, ctx, isDev);
1406
+ }
1407
+
1383
1408
  // Run load function
1384
1409
  if (config?.load && typeof config.load === 'function') {
1385
1410
  const loadData = await config.load(req, ctx);
@@ -1465,6 +1490,16 @@ function mountPages(app, options) {
1465
1490
  html = nunjucks.render(templatePath, renderContext);
1466
1491
  }
1467
1492
 
1493
+ // Apply cascading nested layouts if not opted-out and template does not extend another template
1494
+ const shouldApplyLayout = config?.layout !== false &&
1495
+ ctx.data?.layout !== false &&
1496
+ ctx.meta?.layout !== false &&
1497
+ !templateHasExtends(njkTpl.templateBody, route.fullPath);
1498
+
1499
+ if (shouldApplyLayout && cascadingLayouts.length > 0) {
1500
+ html = applyCascadingLayouts(nunjucks, cascadingLayouts, html, renderContext, isDev);
1501
+ }
1502
+
1468
1503
  if (pluginManager) {
1469
1504
  const contentApi = pluginManager.getPluginAPI('content');
1470
1505
  if (contentApi?.maybeInjectInlineEdit) {
@@ -1535,6 +1570,8 @@ module.exports = {
1535
1570
  mountPages,
1536
1571
  filePathToRoute,
1537
1572
  extractMethodFromFilename,
1573
+ isRouteGroupSegment,
1574
+ stripRouteGroups,
1538
1575
  scanDirectory,
1539
1576
  loadI18n,
1540
1577
  createTranslator,
package/src/helpers.js CHANGED
@@ -278,6 +278,15 @@ class AssetManager {
278
278
  candidates.add(staticParts.join('/'));
279
279
  }
280
280
  }
281
+ if (cleanFile.includes('(')) {
282
+ const withoutGroups = cleanFile.split('/').filter(p => !(p.startsWith('(') && p.endsWith(')'))).join('/');
283
+ if (withoutGroups) {
284
+ candidates.add(withoutGroups);
285
+ if (withoutGroups.endsWith('/index')) {
286
+ candidates.add(withoutGroups.slice(0, -6));
287
+ }
288
+ }
289
+ }
281
290
  if (cleanFile === 'index') {
282
291
  candidates.add('home');
283
292
  }