webspresso 0.0.73 → 0.0.75

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 (65) hide show
  1. package/README.md +44 -4
  2. package/bin/commands/orm-map.js +139 -0
  3. package/bin/commands/skill.js +22 -8
  4. package/bin/commands/upgrade.js +146 -0
  5. package/bin/utils/orm-map-html.js +689 -0
  6. package/bin/utils/orm-map-load.js +85 -0
  7. package/bin/utils/orm-map-snapshot.js +179 -0
  8. package/bin/utils/resolve-webspresso-orm.js +23 -0
  9. package/bin/webspresso.js +4 -0
  10. package/core/auth/manager.js +14 -1
  11. package/core/kernel/app.js +96 -0
  12. package/core/kernel/base-repository.js +143 -0
  13. package/core/kernel/events.js +101 -0
  14. package/core/kernel/flow.js +22 -0
  15. package/core/kernel/index.js +17 -0
  16. package/core/kernel/plugin.js +23 -0
  17. package/core/kernel/plugins/sample-seo.js +26 -0
  18. package/core/kernel/run-demo.js +58 -0
  19. package/core/kernel/view.js +167 -0
  20. package/core/openapi/build-from-api-routes.js +8 -2
  21. package/core/orm/model.js +3 -1
  22. package/core/url-path-normalize.js +30 -0
  23. package/index.d.ts +168 -1
  24. package/index.js +20 -2
  25. package/package.json +11 -1
  26. package/plugins/admin-panel/api.js +43 -15
  27. package/plugins/admin-panel/app.js +109 -0
  28. package/plugins/admin-panel/client/README.md +39 -0
  29. package/plugins/admin-panel/client/load-parts.js +74 -0
  30. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  31. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  32. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  33. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  34. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  35. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  36. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  37. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  38. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  39. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  40. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  41. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  42. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  43. package/plugins/admin-panel/components.js +4 -2640
  44. package/plugins/admin-panel/core/api-extensions.js +100 -10
  45. package/plugins/admin-panel/index.js +3 -0
  46. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  47. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  48. package/plugins/admin-panel/modules/dashboard.js +17 -13
  49. package/plugins/admin-panel/modules/user-management.js +118 -27
  50. package/plugins/data-exchange/export-xlsx.js +3 -0
  51. package/plugins/data-exchange/record-selection.js +21 -5
  52. package/plugins/index.js +4 -0
  53. package/plugins/rate-limit/index.js +178 -0
  54. package/plugins/redirect/index.js +204 -0
  55. package/plugins/rest-resources/index.js +2 -1
  56. package/plugins/site-analytics/admin-component.js +88 -78
  57. package/plugins/swagger.js +2 -1
  58. package/plugins/upload/local-file-provider.js +6 -2
  59. package/src/file-router.js +270 -53
  60. package/src/njk-frontmatter.js +156 -0
  61. package/src/plugin-manager.js +4 -2
  62. package/src/server.js +28 -9
  63. package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
  64. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  65. package/templates/skills/webspresso-usage/SKILL.md +29 -275
@@ -9,6 +9,12 @@ const { ZodError } = require('zod');
9
9
  const { compileSchema, invalidateSchema } = require('../core/compileSchema');
10
10
  const { applySchema } = require('../core/applySchema');
11
11
  const { createHelpers } = require('./helpers');
12
+ const {
13
+ loadNjkRouteTemplate,
14
+ parseNjkFrontmatter,
15
+ frontmatterToPatches,
16
+ clearNjkFrontmatterCaches,
17
+ } = require('./njk-frontmatter');
12
18
 
13
19
  // Cache for i18n files (key: filePath, value: { mtime, data })
14
20
  const i18nCache = new Map();
@@ -22,6 +28,89 @@ const routeConfigDevCache = new Map();
22
28
  // Cache for API filename -> { method, baseName } (basename keys; stable per process)
23
29
  const methodFromFilenameCache = new Map();
24
30
 
31
+ const MAX_LOCALE_LEN = 16;
32
+
33
+ /** @returns {Set<string>} */
34
+ function parseSupportedLocaleSet() {
35
+ const raw = process.env.SUPPORTED_LOCALES || 'en';
36
+ return new Set(
37
+ raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
38
+ );
39
+ }
40
+
41
+ /**
42
+ * @param {string} raw
43
+ * @returns {string|null}
44
+ */
45
+ function normalizeLocaleCandidate(raw) {
46
+ let s = String(raw).trim().toLowerCase().split(';')[0].split(',')[0].trim().replace(/_/g, '-');
47
+ if (s.length < 1 || s.length > MAX_LOCALE_LEN) {
48
+ return null;
49
+ }
50
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s)) {
51
+ return null;
52
+ }
53
+ return s;
54
+ }
55
+
56
+ /**
57
+ * @param {string|null} normalized
58
+ * @param {Set<string>} supported
59
+ * @returns {string|null}
60
+ */
61
+ function pickMatchingLocale(normalized, supported) {
62
+ if (!normalized) {
63
+ return null;
64
+ }
65
+ if (supported.has(normalized)) {
66
+ return normalized;
67
+ }
68
+ const base = normalized.split('-')[0];
69
+ if (supported.has(base)) {
70
+ return base;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Linear-time conversion of [...x] → * then [x] → :x (filesystem route segments only).
77
+ * @param {string} route
78
+ */
79
+ function rewriteDynamicRouteMarkers(route) {
80
+ let i = 0;
81
+ let out = '';
82
+ const n = route.length;
83
+ while (i < n) {
84
+ const open = route.indexOf('[', i);
85
+ if (open === -1) {
86
+ out += route.slice(i);
87
+ break;
88
+ }
89
+ out += route.slice(i, open);
90
+ const close = route.indexOf(']', open + 1);
91
+ if (close === -1) {
92
+ out += route.slice(open);
93
+ break;
94
+ }
95
+ const inner = route.slice(open + 1, close);
96
+ let repl;
97
+ if (inner.startsWith('...') && inner.length > 3) {
98
+ repl = '*';
99
+ } else if (inner.length > 0 && inner.indexOf('[') === -1) {
100
+ repl = ':' + inner;
101
+ } else {
102
+ repl = route.slice(open, close + 1);
103
+ }
104
+ out += repl;
105
+ i = close + 1;
106
+ }
107
+ return out;
108
+ }
109
+
110
+ function escapeRegExp(s) {
111
+ return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
112
+ }
113
+
25
114
  /**
26
115
  * Convert a file path to an Express route pattern
27
116
  * @param {string} filePath - Relative path from pages/
@@ -43,12 +132,9 @@ function filePathToRoute(filePath, ext) {
43
132
  route = '/';
44
133
  }
45
134
 
46
- // Convert [param] to :param
47
- route = route.replace(/\[([^\]\.]+)\]/g, ':$1');
48
-
49
- // Convert [...param] to * (catch-all)
50
- route = route.replace(/\[\.\.\.([^\]]+)\]/g, '*');
51
-
135
+ // Convert [param] / [...param] without regex backtracking hazards
136
+ route = rewriteDynamicRouteMarkers(route);
137
+
52
138
  // Ensure leading slash
53
139
  if (!route.startsWith('/')) {
54
140
  route = '/' + route;
@@ -65,27 +151,50 @@ function filePathToRoute(filePath, ext) {
65
151
  * @returns {{ tier: number, literalSegCount: number, paramSegCount: number, depth: number, routePath: string }}
66
152
  */
67
153
  function routeRegistrationMeta(routePath) {
68
- const hasCatchAll = routePath.includes('*');
69
- const hasDynamic = routePath.includes(':');
70
- let tier;
71
- if (hasCatchAll) tier = 2;
72
- else if (hasDynamic) tier = 1;
73
- else tier = 0;
74
-
75
- const segments = routePath.split('/').filter(Boolean);
154
+ let pathHasStar = false;
155
+ let pathHasColon = false;
156
+ let depth = 0;
76
157
  let literalSegCount = 0;
77
158
  let paramSegCount = 0;
78
- for (const seg of segments) {
79
- if (seg === '*' || (seg.length > 0 && seg.includes('*'))) continue;
80
- if (seg.includes(':')) paramSegCount += 1;
159
+
160
+ const s = routePath;
161
+ const n = s.length;
162
+ let i = 0;
163
+ while (i < n) {
164
+ while (i < n && s.charCodeAt(i) === 47 /* / */) i++;
165
+ if (i >= n) break;
166
+ const start = i;
167
+ while (i < n && s.charCodeAt(i) !== 47) i++;
168
+
169
+ depth++;
170
+ let segHasStar = false;
171
+ let segHasColon = false;
172
+ for (let j = start; j < i; j++) {
173
+ const c = s.charCodeAt(j);
174
+ if (c === 42 /* * */) segHasStar = true;
175
+ else if (c === 58 /* : */) segHasColon = true;
176
+ }
177
+ if (segHasStar) pathHasStar = true;
178
+ if (segHasColon) pathHasColon = true;
179
+
180
+ if (segHasStar) {
181
+ // Same as: seg === '*' || (seg.length > 0 && seg.includes('*'))
182
+ continue;
183
+ }
184
+ if (segHasColon) paramSegCount += 1;
81
185
  else literalSegCount += 1;
82
186
  }
83
187
 
188
+ let tier;
189
+ if (pathHasStar) tier = 2;
190
+ else if (pathHasColon) tier = 1;
191
+ else tier = 0;
192
+
84
193
  return {
85
194
  tier,
86
195
  literalSegCount,
87
196
  paramSegCount,
88
- depth: segments.length,
197
+ depth,
89
198
  routePath,
90
199
  };
91
200
  }
@@ -140,6 +249,69 @@ function extractMethodFromFilename(filename) {
140
249
  return result;
141
250
  }
142
251
 
252
+ /**
253
+ * Whether `load()` return values for `stylesheets` and `scripts` are promoted to
254
+ * `pageHead` in Nunjucks (see `createApp({ pageAssets })`).
255
+ * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}|null|undefined} raw
256
+ * @returns {{ enabled: boolean, stylesheets: boolean, scripts: boolean }}
257
+ */
258
+ function resolvePageAssets(raw) {
259
+ if (raw === true) {
260
+ return { enabled: true, stylesheets: true, scripts: true };
261
+ }
262
+ if (raw == null || raw === false) {
263
+ return { enabled: false, stylesheets: false, scripts: false };
264
+ }
265
+ if (typeof raw === 'object') {
266
+ const on = raw.enabled !== false;
267
+ if (!on) {
268
+ return { enabled: false, stylesheets: false, scripts: false };
269
+ }
270
+ return {
271
+ enabled: true,
272
+ stylesheets: raw.stylesheets !== false,
273
+ scripts: raw.scripts !== false,
274
+ };
275
+ }
276
+ return { enabled: false, stylesheets: false, scripts: false };
277
+ }
278
+
279
+ /**
280
+ * @param {unknown} v
281
+ * @returns {unknown[]}
282
+ */
283
+ function toList(v) {
284
+ if (v == null) return [];
285
+ return Array.isArray(v) ? v : [v];
286
+ }
287
+
288
+ /**
289
+ * @param {{ enabled: boolean, stylesheets: boolean, scripts: boolean }} cfg
290
+ * @param {Object} data
291
+ * @returns {{ data: Object, pageHead: { stylesheets: unknown[], scripts: unknown[] }|null, pageAssets: boolean }}
292
+ */
293
+ function applyPageAssetsToTemplateData(cfg, data) {
294
+ if (!cfg || !cfg.enabled) {
295
+ return { data, pageHead: null, pageAssets: false };
296
+ }
297
+ const out = { ...data };
298
+ let styles = [];
299
+ let scriptItems = [];
300
+ if (cfg.stylesheets && Object.prototype.hasOwnProperty.call(out, 'stylesheets')) {
301
+ styles = toList(out.stylesheets);
302
+ delete out.stylesheets;
303
+ }
304
+ if (cfg.scripts && Object.prototype.hasOwnProperty.call(out, 'scripts')) {
305
+ scriptItems = toList(out.scripts);
306
+ delete out.scripts;
307
+ }
308
+ return {
309
+ data: out,
310
+ pageHead: { stylesheets: styles, scripts: scriptItems },
311
+ pageAssets: true,
312
+ };
313
+ }
314
+
143
315
  /**
144
316
  * Recursively scan a directory for files
145
317
  * @param {string} dir - Directory to scan
@@ -197,7 +369,7 @@ function loadI18nFile(filePath) {
197
369
  i18nCache.set(filePath, { mtime: stats.mtimeMs, data });
198
370
  return data;
199
371
  } catch (err) {
200
- console.error(`Error loading i18n file ${filePath}:`, err.message);
372
+ console.error('Error loading i18n file:', filePath, err.message);
201
373
  return {};
202
374
  }
203
375
  }
@@ -252,7 +424,8 @@ function createTranslator(translations) {
252
424
  // Replace params like {{name}} in the translation
253
425
  if (typeof value === 'string' && Object.keys(params).length > 0) {
254
426
  for (const [paramKey, paramValue] of Object.entries(params)) {
255
- value = value.replace(new RegExp(`{{\\s*${paramKey}\\s*}}`, 'g'), paramValue);
427
+ const escaped = escapeRegExp(paramKey);
428
+ value = value.replace(new RegExp(`{{\\s*${escaped}\\s*}}`, 'g'), paramValue);
256
429
  }
257
430
  }
258
431
 
@@ -295,7 +468,7 @@ function loadRouteConfig(configPath, isDev) {
295
468
  configCache.set(configPath, config);
296
469
  return config;
297
470
  } catch (err) {
298
- console.error(`Error loading route config ${configPath}:`, err.message);
471
+ console.error('Error loading route config:', configPath, err.message);
299
472
  return null;
300
473
  }
301
474
  }
@@ -317,9 +490,9 @@ function loadGlobalHooks(pagesDir, isDev) {
317
490
  * @param {string} hookName - Hook name
318
491
  * @param {Object} ctx - Context object
319
492
  */
320
- async function executeHook(hooks, hookName, ctx) {
493
+ async function executeHook(hooks, hookName, ctx, ...extra) {
321
494
  if (hooks && typeof hooks[hookName] === 'function') {
322
- await hooks[hookName](ctx);
495
+ await hooks[hookName](ctx, ...extra);
323
496
  }
324
497
  }
325
498
 
@@ -329,23 +502,32 @@ async function executeHook(hooks, hookName, ctx) {
329
502
  * @returns {string} Locale code
330
503
  */
331
504
  function detectLocale(req) {
332
- // 1. Check query parameter
333
- if (req.query.lang) {
334
- return req.query.lang;
505
+ const supported = parseSupportedLocaleSet();
506
+ const defaultCand = normalizeLocaleCandidate(process.env.DEFAULT_LOCALE || 'en');
507
+ const def =
508
+ pickMatchingLocale(defaultCand, supported)
509
+ ?? (supported.has('en') ? 'en' : [...supported][0])
510
+ ?? 'en';
511
+
512
+ if (req.query && req.query.lang != null && req.query.lang !== '') {
513
+ const q = normalizeLocaleCandidate(String(req.query.lang));
514
+ const hit = pickMatchingLocale(q, supported);
515
+ if (hit) {
516
+ return hit;
517
+ }
335
518
  }
336
-
337
- // 2. Check Accept-Language header
519
+
338
520
  const acceptLanguage = req.get('Accept-Language');
339
521
  if (acceptLanguage) {
340
- const supported = (process.env.SUPPORTED_LOCALES || 'en').split(',');
341
- const preferred = acceptLanguage.split(',')[0].split('-')[0].toLowerCase();
342
- if (supported.includes(preferred)) {
343
- return preferred;
522
+ const langPart = acceptLanguage.split(',')[0];
523
+ const a = normalizeLocaleCandidate(langPart);
524
+ const hit = pickMatchingLocale(a, supported);
525
+ if (hit) {
526
+ return hit;
344
527
  }
345
528
  }
346
-
347
- // 3. Default locale
348
- return process.env.DEFAULT_LOCALE || 'en';
529
+
530
+ return def;
349
531
  }
350
532
 
351
533
  /**
@@ -439,6 +621,7 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
439
621
  * @param {boolean} options.silent - Suppress console output
440
622
  * @param {Object} options.db - Database instance (exposed as ctx.db in load/meta)
441
623
  * @param {{ alpine?: boolean, swup?: boolean }} [options.clientRuntime] - Passed to Nunjucks as `clientRuntime` (default both false)
624
+ * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}} [options.pageAssets] - If set, `load()` may return `stylesheets` / `scripts` promoted to `pageHead` in templates
442
625
  * @returns {Array} Route metadata for plugins
443
626
  */
444
627
  function mountPages(app, options) {
@@ -450,7 +633,9 @@ function mountPages(app, options) {
450
633
  silent = false,
451
634
  db = null,
452
635
  clientRuntime: clientRuntimeOpt = null,
636
+ pageAssets: pageAssetsOpt = null,
453
637
  } = options;
638
+ const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
454
639
  const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
455
640
  ? { alpine: !!clientRuntimeOpt.alpine, swup: !!clientRuntimeOpt.swup }
456
641
  : { alpine: false, swup: false };
@@ -485,7 +670,7 @@ function mountPages(app, options) {
485
670
  apiRoutes.push({
486
671
  file,
487
672
  method,
488
- routePath: filePathToRoute(routePath.replace(/^\/api/, '/api'), ''),
673
+ routePath: filePathToRoute(routePath, ''),
489
674
  fullPath: path.join(absolutePagesDir, file)
490
675
  });
491
676
  } else if (ext === '.njk') {
@@ -520,6 +705,10 @@ function mountPages(app, options) {
520
705
  const handler = require(route.fullPath);
521
706
  const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
522
707
  const routeMiddleware = handler.middleware;
708
+
709
+ const preResolvedMw = routeMiddleware
710
+ ? resolveMiddlewares(routeMiddleware, middlewares)
711
+ : [];
523
712
 
524
713
  if (typeof handlerFn !== 'function') {
525
714
  console.warn(`API route ${route.file} does not export a function`);
@@ -568,11 +757,9 @@ function mountPages(app, options) {
568
757
  throw err;
569
758
  }
570
759
 
571
- // Run middleware if defined
572
- const mwConfig = isDev ? currentHandler.middleware : routeMiddleware;
573
- if (mwConfig) {
574
- const resolvedMw = resolveMiddlewares(mwConfig, middlewares);
575
- for (const mw of resolvedMw) {
760
+ // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
761
+ if (preResolvedMw.length) {
762
+ for (const mw of preResolvedMw) {
576
763
  await new Promise((resolve, reject) => {
577
764
  mw(req, res, (err) => {
578
765
  if (err) reject(err);
@@ -585,7 +772,13 @@ function mountPages(app, options) {
585
772
  await fn(req, res, next);
586
773
  } catch (err) {
587
774
  console.error(`API error ${route.routePath}:`, err);
588
- res.status(500).json({ error: 'Internal Server Error' });
775
+ const hookCtx = { req, res, error: err };
776
+ try {
777
+ await executeHook(globalHooks, 'onError', hookCtx, err);
778
+ } catch (hookErr) {
779
+ console.error('Error in onError hook:', hookErr);
780
+ }
781
+ return next(err);
589
782
  }
590
783
  });
591
784
 
@@ -596,6 +789,11 @@ function mountPages(app, options) {
596
789
  /** Register SSR GET routes (shared by static phase and dynamic phase). */
597
790
  const registerSsrRoutes = (routes) => {
598
791
  for (const route of routes) {
792
+ const mountConfig = loadRouteConfig(route.configPath, isDev);
793
+ const preResolvedPageMw = mountConfig?.middleware
794
+ ? resolveMiddlewares(mountConfig.middleware, middlewares)
795
+ : [];
796
+
599
797
  app.get(route.routePath, async (req, res, next) => {
600
798
  try {
601
799
  // Detect locale
@@ -613,6 +811,8 @@ function mountPages(app, options) {
613
811
  const baseHelpers = createHelpers({ req, res, locale });
614
812
  const pluginHelpers = pluginManager ? pluginManager.getHelpers() : {};
615
813
 
814
+ const njkTpl = loadNjkRouteTemplate(route.fullPath, isDev);
815
+
616
816
  const ctx = {
617
817
  req,
618
818
  res,
@@ -622,12 +822,13 @@ function mountPages(app, options) {
622
822
  routeDir: route.routeDir,
623
823
  locale,
624
824
  t,
625
- data: {},
825
+ data: { ...njkTpl.dataPatch },
626
826
  meta: {
627
827
  title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
628
828
  description: t('meta.description') !== 'meta.description' ? t('meta.description') : null,
629
829
  indexable: true,
630
- canonical: null
830
+ canonical: null,
831
+ ...njkTpl.metaPatch,
631
832
  },
632
833
  fsy: { ...baseHelpers, ...pluginHelpers },
633
834
  clientRuntime,
@@ -645,10 +846,9 @@ function mountPages(app, options) {
645
846
  await executeHook(globalHooks, 'beforeMiddleware', ctx);
646
847
  await executeHook(routeHooks, 'beforeMiddleware', ctx);
647
848
 
648
- // Run route middleware
649
- if (config?.middleware) {
650
- const resolvedMiddlewares = resolveMiddlewares(config.middleware, middlewares);
651
- for (const mw of resolvedMiddlewares) {
849
+ // Run route middleware (chain fixed at route registration; edit middleware in dev → restart)
850
+ if (preResolvedPageMw.length) {
851
+ for (const mw of preResolvedPageMw) {
652
852
  await new Promise((resolve, reject) => {
653
853
  mw(req, res, (err) => {
654
854
  if (err) reject(err);
@@ -686,9 +886,9 @@ function mountPages(app, options) {
686
886
  await executeHook(globalHooks, 'beforeRender', ctx);
687
887
  await executeHook(routeHooks, 'beforeRender', ctx);
688
888
 
689
- // Render the template
690
- const templatePath = route.file.split(path.sep).join('/');
691
- const html = nunjucks.render(templatePath, {
889
+ const pageAssetBundle = applyPageAssetsToTemplateData(pageAssetsResolved, ctx.data);
890
+ ctx.data = pageAssetBundle.data;
891
+ const renderContext = {
692
892
  ...ctx.data,
693
893
  meta: ctx.meta,
694
894
  locale: ctx.locale,
@@ -700,7 +900,18 @@ function mountPages(app, options) {
700
900
  query: req.query,
701
901
  params: req.params
702
902
  }
703
- });
903
+ };
904
+ if (pageAssetBundle.pageAssets) {
905
+ renderContext.pageAssets = true;
906
+ renderContext.pageHead = pageAssetBundle.pageHead;
907
+ }
908
+
909
+ // Render the template
910
+ const templatePath = route.file.split(path.sep).join('/');
911
+ const html =
912
+ njkTpl.useStringRender && njkTpl.templateBody != null
913
+ ? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
914
+ : nunjucks.render(templatePath, renderContext);
704
915
 
705
916
  // Execute hooks: afterRender
706
917
  ctx.html = html;
@@ -719,7 +930,7 @@ function mountPages(app, options) {
719
930
  console.error('Error in onError hook:', hookErr);
720
931
  }
721
932
 
722
- res.status(500).send('Internal Server Error');
933
+ return next(err);
723
934
  }
724
935
  });
725
936
 
@@ -769,5 +980,11 @@ module.exports = {
769
980
  resolveMiddlewares,
770
981
  routeRegistrationMeta,
771
982
  compareRouteRegistrationOrder,
983
+ resolvePageAssets,
984
+ applyPageAssetsToTemplateData,
985
+ parseNjkFrontmatter,
986
+ frontmatterToPatches,
987
+ loadNjkRouteTemplate,
988
+ clearNjkFrontmatterCaches,
772
989
  };
773
990
 
@@ -0,0 +1,156 @@
1
+ /**
2
+ * YAML frontmatter for pages/*.njk (optional).
3
+ * Opens with --- … --- at file top; merges into ctx.meta / ctx.data before route hooks/load.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const { parse } = require('yaml');
8
+
9
+ const FRONTMATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
10
+
11
+ /** @typedef {{ metaPatch: Record<string, unknown>, dataPatch: Record<string, unknown> }} Patch */
12
+
13
+ const prodRouteCache = new Map();
14
+ /** @type {Map<string, { mtimeMs: number, cached: LoadedNjk }>} */
15
+ const devRouteCache = new Map();
16
+
17
+ /** @typedef {{ useStringRender: boolean, templateBody: string|null, metaPatch: Record<string, unknown>, dataPatch: Record<string, unknown> }} LoadedNjk */
18
+
19
+ /**
20
+ * @param {string} raw
21
+ * @returns {{ body: string, yamlText: string|null, extracted: boolean }}
22
+ */
23
+ function extractFrontmatterBlock(raw) {
24
+ const strippedBom = raw.replace(/^\uFEFF/, '');
25
+ const match = strippedBom.match(FRONTMATTER_BLOCK);
26
+ if (!match) {
27
+ return { body: strippedBom, yamlText: null, extracted: false };
28
+ }
29
+ const body = strippedBom.slice(match[0].length);
30
+ const yamlText = match[1] != null ? String(match[1]).trimEnd() : '';
31
+ return { body, yamlText, extracted: true };
32
+ }
33
+
34
+ /**
35
+ * @param {string} content Full .njk file contents
36
+ */
37
+ function parseNjkFrontmatter(content) {
38
+ const { body, yamlText, extracted } = extractFrontmatterBlock(content);
39
+ if (!extracted || yamlText == null) {
40
+ return { body, fm: null, hasDelimiter: extracted };
41
+ }
42
+ if (yamlText === '') {
43
+ return { body, fm: {}, hasDelimiter: true };
44
+ }
45
+ try {
46
+ const fm = parse(yamlText);
47
+ return {
48
+ body,
49
+ fm: fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : null,
50
+ hasDelimiter: true,
51
+ };
52
+ } catch (err) {
53
+ console.warn('[webspresso] .njk frontmatter YAML parse failed — rendering without fm meta:', err.message);
54
+ return { body, fm: null, hasDelimiter: true };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * @param {unknown} fm
60
+ * @returns {Patch}
61
+ */
62
+ function frontmatterToPatches(fm) {
63
+ if (!fm || typeof fm !== 'object' || Array.isArray(fm)) {
64
+ return { metaPatch: {}, dataPatch: {} };
65
+ }
66
+
67
+ /** @type {Record<string, unknown>} */
68
+ const metaPatch = {};
69
+ const fmObj = fm;
70
+ const nestedMeta = fmObj.meta;
71
+ if (nestedMeta && typeof nestedMeta === 'object' && !Array.isArray(nestedMeta)) {
72
+ Object.assign(metaPatch, nestedMeta);
73
+ }
74
+ for (const k of ['title', 'description', 'canonical', 'indexable']) {
75
+ if (Object.prototype.hasOwnProperty.call(fmObj, k) && fmObj[k] !== undefined) {
76
+ metaPatch[k] = fmObj[k];
77
+ }
78
+ }
79
+
80
+ /** @type {Record<string, unknown>} */
81
+ let dataPatch = {};
82
+ const d = fmObj.data;
83
+ if (d && typeof d === 'object' && !Array.isArray(d)) {
84
+ dataPatch = { ...d };
85
+ }
86
+
87
+ return { metaPatch, dataPatch };
88
+ }
89
+
90
+ /**
91
+ * @param {boolean} [isDevLike]
92
+ */
93
+ function readAndParse(absPath, isDevLike = false) {
94
+ const raw = fs.readFileSync(absPath, 'utf8');
95
+ const { body, fm, hasDelimiter } = parseNjkFrontmatter(raw);
96
+ const patches = frontmatterToPatches(fm);
97
+
98
+ /** @type {LoadedNjk} */
99
+ const loaded = {
100
+ useStringRender: !!hasDelimiter,
101
+ templateBody: hasDelimiter ? body : null,
102
+ metaPatch: patches.metaPatch,
103
+ dataPatch: patches.dataPatch,
104
+ };
105
+
106
+ if (isDevLike) {
107
+ return loaded;
108
+ }
109
+ prodRouteCache.set(absPath, loaded);
110
+ return loaded;
111
+ }
112
+
113
+ /**
114
+ * @param {string} absPath Absolute path to pages/…/*.njk
115
+ * @param {boolean} isDev NODE_ENV !== 'production' style
116
+ */
117
+ function loadNjkRouteTemplate(absPath, isDev) {
118
+ if (!fs.existsSync(absPath)) {
119
+ return {
120
+ useStringRender: false,
121
+ templateBody: null,
122
+ metaPatch: {},
123
+ dataPatch: {},
124
+ };
125
+ }
126
+
127
+ const stat = fs.statSync(absPath);
128
+
129
+ if (isDev) {
130
+ const prev = devRouteCache.get(absPath);
131
+ if (prev && prev.mtimeMs >= stat.mtimeMs) {
132
+ return prev.cached;
133
+ }
134
+ const fresh = readAndParse(absPath, true);
135
+ devRouteCache.set(absPath, { mtimeMs: stat.mtimeMs, cached: fresh });
136
+ return fresh;
137
+ }
138
+
139
+ if (prodRouteCache.has(absPath)) {
140
+ return prodRouteCache.get(absPath);
141
+ }
142
+ return readAndParse(absPath, false);
143
+ }
144
+
145
+ function clearNjkFrontmatterCaches() {
146
+ prodRouteCache.clear();
147
+ devRouteCache.clear();
148
+ }
149
+
150
+ module.exports = {
151
+ parseNjkFrontmatter,
152
+ frontmatterToPatches,
153
+ loadNjkRouteTemplate,
154
+ clearNjkFrontmatterCaches,
155
+ extractFrontmatterBlock,
156
+ };
@@ -130,7 +130,7 @@ class PluginManager {
130
130
  /**
131
131
  * Register plugins with the manager (async version)
132
132
  * @param {Array} plugins - Array of plugin definitions or factory functions
133
- * @param {Object} context - Context object { app, nunjucksEnv, options }
133
+ * @param {Object} context - Context object `{ app, nunjucksEnv, options, middlewares? }`
134
134
  */
135
135
  async register(plugins, context) {
136
136
  if (!plugins || !Array.isArray(plugins)) return;
@@ -159,7 +159,7 @@ class PluginManager {
159
159
  /**
160
160
  * Register plugins with the manager (sync version)
161
161
  * @param {Array} plugins - Array of plugin definitions or factory functions
162
- * @param {Object} context - Context object { app, nunjucksEnv, options }
162
+ * @param {Object} context - Context object `{ app, nunjucksEnv, options, middlewares? }`
163
163
  */
164
164
  registerSync(plugins, context) {
165
165
  if (!plugins || !Array.isArray(plugins)) return;
@@ -372,6 +372,8 @@ class PluginManager {
372
372
  options: plugin._options || {},
373
373
  nunjucksEnv: context.nunjucksEnv,
374
374
  db: context.options?.db ?? null,
375
+ /** @type {Record<string, unknown>} Named middleware registry (same reference as createApp → mountPages) */
376
+ middlewares: context.middlewares ?? {},
375
377
 
376
378
  /**
377
379
  * Get another plugin's API