webspresso 0.0.74 → 0.0.76

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 (61) hide show
  1. package/README.md +66 -4
  2. package/bin/commands/admin-password.js +21 -57
  3. package/bin/commands/orm-map.js +139 -0
  4. package/bin/commands/skill.js +22 -8
  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 +2 -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/client/README.md +39 -0
  28. package/plugins/admin-panel/client/load-parts.js +74 -0
  29. package/plugins/admin-panel/client/manifest.parts.json +12 -0
  30. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
  31. package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
  32. package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
  33. package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
  34. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
  35. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
  36. package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
  37. package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
  38. package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
  39. package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
  40. package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
  41. package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
  42. package/plugins/admin-panel/components.js +4 -2640
  43. package/plugins/admin-panel/core/api-extensions.js +100 -10
  44. package/plugins/admin-panel/index.js +3 -0
  45. package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
  46. package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
  47. package/plugins/admin-panel/modules/dashboard.js +3 -2
  48. package/plugins/admin-panel/modules/user-management.js +90 -20
  49. package/plugins/index.js +4 -0
  50. package/plugins/rate-limit/index.js +178 -0
  51. package/plugins/redirect/index.js +204 -0
  52. package/plugins/rest-resources/index.js +2 -1
  53. package/plugins/swagger.js +2 -1
  54. package/plugins/upload/local-file-provider.js +6 -2
  55. package/src/file-router.js +191 -50
  56. package/src/njk-frontmatter.js +156 -0
  57. package/src/plugin-manager.js +4 -2
  58. package/src/server.js +26 -9
  59. package/templates/skills/webspresso-usage/REFERENCE-framework.md +277 -0
  60. package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
  61. package/templates/skills/webspresso-usage/SKILL.md +29 -278
@@ -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
  }
@@ -260,7 +369,7 @@ function loadI18nFile(filePath) {
260
369
  i18nCache.set(filePath, { mtime: stats.mtimeMs, data });
261
370
  return data;
262
371
  } catch (err) {
263
- console.error(`Error loading i18n file ${filePath}:`, err.message);
372
+ console.error('Error loading i18n file:', filePath, err.message);
264
373
  return {};
265
374
  }
266
375
  }
@@ -315,7 +424,8 @@ function createTranslator(translations) {
315
424
  // Replace params like {{name}} in the translation
316
425
  if (typeof value === 'string' && Object.keys(params).length > 0) {
317
426
  for (const [paramKey, paramValue] of Object.entries(params)) {
318
- 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);
319
429
  }
320
430
  }
321
431
 
@@ -358,7 +468,7 @@ function loadRouteConfig(configPath, isDev) {
358
468
  configCache.set(configPath, config);
359
469
  return config;
360
470
  } catch (err) {
361
- console.error(`Error loading route config ${configPath}:`, err.message);
471
+ console.error('Error loading route config:', configPath, err.message);
362
472
  return null;
363
473
  }
364
474
  }
@@ -380,9 +490,9 @@ function loadGlobalHooks(pagesDir, isDev) {
380
490
  * @param {string} hookName - Hook name
381
491
  * @param {Object} ctx - Context object
382
492
  */
383
- async function executeHook(hooks, hookName, ctx) {
493
+ async function executeHook(hooks, hookName, ctx, ...extra) {
384
494
  if (hooks && typeof hooks[hookName] === 'function') {
385
- await hooks[hookName](ctx);
495
+ await hooks[hookName](ctx, ...extra);
386
496
  }
387
497
  }
388
498
 
@@ -392,23 +502,32 @@ async function executeHook(hooks, hookName, ctx) {
392
502
  * @returns {string} Locale code
393
503
  */
394
504
  function detectLocale(req) {
395
- // 1. Check query parameter
396
- if (req.query.lang) {
397
- 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
+ }
398
518
  }
399
-
400
- // 2. Check Accept-Language header
519
+
401
520
  const acceptLanguage = req.get('Accept-Language');
402
521
  if (acceptLanguage) {
403
- const supported = (process.env.SUPPORTED_LOCALES || 'en').split(',');
404
- const preferred = acceptLanguage.split(',')[0].split('-')[0].toLowerCase();
405
- if (supported.includes(preferred)) {
406
- 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;
407
527
  }
408
528
  }
409
-
410
- // 3. Default locale
411
- return process.env.DEFAULT_LOCALE || 'en';
529
+
530
+ return def;
412
531
  }
413
532
 
414
533
  /**
@@ -551,7 +670,7 @@ function mountPages(app, options) {
551
670
  apiRoutes.push({
552
671
  file,
553
672
  method,
554
- routePath: filePathToRoute(routePath.replace(/^\/api/, '/api'), ''),
673
+ routePath: filePathToRoute(routePath, ''),
555
674
  fullPath: path.join(absolutePagesDir, file)
556
675
  });
557
676
  } else if (ext === '.njk') {
@@ -586,6 +705,10 @@ function mountPages(app, options) {
586
705
  const handler = require(route.fullPath);
587
706
  const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
588
707
  const routeMiddleware = handler.middleware;
708
+
709
+ const preResolvedMw = routeMiddleware
710
+ ? resolveMiddlewares(routeMiddleware, middlewares)
711
+ : [];
589
712
 
590
713
  if (typeof handlerFn !== 'function') {
591
714
  console.warn(`API route ${route.file} does not export a function`);
@@ -634,11 +757,9 @@ function mountPages(app, options) {
634
757
  throw err;
635
758
  }
636
759
 
637
- // Run middleware if defined
638
- const mwConfig = isDev ? currentHandler.middleware : routeMiddleware;
639
- if (mwConfig) {
640
- const resolvedMw = resolveMiddlewares(mwConfig, middlewares);
641
- 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) {
642
763
  await new Promise((resolve, reject) => {
643
764
  mw(req, res, (err) => {
644
765
  if (err) reject(err);
@@ -651,7 +772,13 @@ function mountPages(app, options) {
651
772
  await fn(req, res, next);
652
773
  } catch (err) {
653
774
  console.error(`API error ${route.routePath}:`, err);
654
- 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);
655
782
  }
656
783
  });
657
784
 
@@ -662,6 +789,11 @@ function mountPages(app, options) {
662
789
  /** Register SSR GET routes (shared by static phase and dynamic phase). */
663
790
  const registerSsrRoutes = (routes) => {
664
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
+
665
797
  app.get(route.routePath, async (req, res, next) => {
666
798
  try {
667
799
  // Detect locale
@@ -679,6 +811,8 @@ function mountPages(app, options) {
679
811
  const baseHelpers = createHelpers({ req, res, locale });
680
812
  const pluginHelpers = pluginManager ? pluginManager.getHelpers() : {};
681
813
 
814
+ const njkTpl = loadNjkRouteTemplate(route.fullPath, isDev);
815
+
682
816
  const ctx = {
683
817
  req,
684
818
  res,
@@ -688,12 +822,13 @@ function mountPages(app, options) {
688
822
  routeDir: route.routeDir,
689
823
  locale,
690
824
  t,
691
- data: {},
825
+ data: { ...njkTpl.dataPatch },
692
826
  meta: {
693
827
  title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
694
828
  description: t('meta.description') !== 'meta.description' ? t('meta.description') : null,
695
829
  indexable: true,
696
- canonical: null
830
+ canonical: null,
831
+ ...njkTpl.metaPatch,
697
832
  },
698
833
  fsy: { ...baseHelpers, ...pluginHelpers },
699
834
  clientRuntime,
@@ -711,10 +846,9 @@ function mountPages(app, options) {
711
846
  await executeHook(globalHooks, 'beforeMiddleware', ctx);
712
847
  await executeHook(routeHooks, 'beforeMiddleware', ctx);
713
848
 
714
- // Run route middleware
715
- if (config?.middleware) {
716
- const resolvedMiddlewares = resolveMiddlewares(config.middleware, middlewares);
717
- 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) {
718
852
  await new Promise((resolve, reject) => {
719
853
  mw(req, res, (err) => {
720
854
  if (err) reject(err);
@@ -774,7 +908,10 @@ function mountPages(app, options) {
774
908
 
775
909
  // Render the template
776
910
  const templatePath = route.file.split(path.sep).join('/');
777
- const html = nunjucks.render(templatePath, renderContext);
911
+ const html =
912
+ njkTpl.useStringRender && njkTpl.templateBody != null
913
+ ? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
914
+ : nunjucks.render(templatePath, renderContext);
778
915
 
779
916
  // Execute hooks: afterRender
780
917
  ctx.html = html;
@@ -793,7 +930,7 @@ function mountPages(app, options) {
793
930
  console.error('Error in onError hook:', hookErr);
794
931
  }
795
932
 
796
- res.status(500).send('Internal Server Error');
933
+ return next(err);
797
934
  }
798
935
  });
799
936
 
@@ -845,5 +982,9 @@ module.exports = {
845
982
  compareRouteRegistrationOrder,
846
983
  resolvePageAssets,
847
984
  applyPageAssetsToTemplateData,
985
+ parseNjkFrontmatter,
986
+ frontmatterToPatches,
987
+ loadNjkRouteTemplate,
988
+ clearNjkFrontmatterCaches,
848
989
  };
849
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
package/src/server.js CHANGED
@@ -11,7 +11,7 @@ const timeout = require('connect-timeout');
11
11
  const { setAppContext } = require('./app-context');
12
12
  const { mountClientRuntime } = require('./client-runtime/mount');
13
13
  const { resolveClientRuntime } = require('./client-runtime/resolve');
14
- const { mountPages } = require('./file-router');
14
+ const { mountPages, detectLocale } = require('./file-router');
15
15
  const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
16
16
  const { createPluginManager } = require('./plugin-manager');
17
17
 
@@ -57,6 +57,16 @@ function getDefaultHelmetConfig(isDev) {
57
57
  };
58
58
  }
59
59
 
60
+ /**
61
+ * Use JSON error responses for `pages/api/*` routes and clients that do not prefer HTML.
62
+ * @param {import('express').Request} req
63
+ * @returns {boolean}
64
+ */
65
+ function preferJsonErrorResponse(req) {
66
+ if (req.path && req.path.startsWith('/api')) return true;
67
+ return !req.accepts('html');
68
+ }
69
+
60
70
  /**
61
71
  * Shared CSS for built-in HTML error pages (viewport-safe, fluid type, dark mode)
62
72
  */
@@ -376,6 +386,12 @@ function createApp(options = {}) {
376
386
  middlewares.auth = authMiddleware.auth;
377
387
  middlewares.guest = authMiddleware.guest;
378
388
  }
389
+
390
+ // Under Vitest, shared API fixtures use `fixtureRequireAuth`; default no-op unless overridden.
391
+ const runsUnderVitest = process.env.VITEST === 'true' || process.env.VITEST_WORKER_ID !== undefined;
392
+ if (runsUnderVitest && middlewares.fixtureRequireAuth == null) {
393
+ middlewares.fixtureRequireAuth = (req, res, next) => next();
394
+ }
379
395
 
380
396
  // Static files (if publicDir provided)
381
397
  if (publicDir) {
@@ -421,8 +437,8 @@ function createApp(options = {}) {
421
437
  return d.toString();
422
438
  });
423
439
 
424
- // Register plugins (sync)
425
- const pluginContext = { app, nunjucksEnv, options };
440
+ // Register plugins (sync) — middlewares is the same object later passed to mountPages
441
+ const pluginContext = { app, nunjucksEnv, options, middlewares };
426
442
  pluginManager.registerSync(plugins, pluginContext);
427
443
 
428
444
  // Request logging middleware
@@ -463,6 +479,7 @@ function createApp(options = {}) {
463
479
  app,
464
480
  nunjucksEnv,
465
481
  options,
482
+ middlewares,
466
483
  db: options.db ?? null,
467
484
  routes: pluginManager.routes,
468
485
  usePlugin: (n) => pluginManager.getPluginAPI(n),
@@ -501,7 +518,7 @@ function createApp(options = {}) {
501
518
  // Helper to create error page context with fsy
502
519
  function createErrorContext(req, extraData = {}) {
503
520
  const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
504
- const locale = req.query?.lang || process.env.DEFAULT_LOCALE || 'en';
521
+ const locale = detectLocale(req);
505
522
 
506
523
  // Create fsy helpers
507
524
  const fsy = createHelpers({ req, res: {}, baseUrl, locale });
@@ -562,7 +579,7 @@ function createApp(options = {}) {
562
579
  }
563
580
 
564
581
  // Custom timeout template
565
- if (typeof errorPages.timeout === 'string') {
582
+ if (typeof errorPages.timeout === 'string' && !preferJsonErrorResponse(req)) {
566
583
  try {
567
584
  const html = nunjucksEnv.render(errorPages.timeout, ctx);
568
585
  return res.send(html);
@@ -572,7 +589,7 @@ function createApp(options = {}) {
572
589
  }
573
590
 
574
591
  // Default timeout response
575
- if (req.accepts('html')) {
592
+ if (!preferJsonErrorResponse(req)) {
576
593
  return res.send(default503Html());
577
594
  } else {
578
595
  return res.json({ error: 'Request Timeout', status: 503 });
@@ -591,8 +608,8 @@ function createApp(options = {}) {
591
608
  return errorPages.serverError(err, req, res, ctx);
592
609
  }
593
610
 
594
- // Custom template
595
- if (typeof errorPages.serverError === 'string') {
611
+ // Custom template (skipped for /api and JSON-preferring clients so they never get HTML)
612
+ if (typeof errorPages.serverError === 'string' && !preferJsonErrorResponse(req)) {
596
613
  try {
597
614
  const html = nunjucksEnv.render(errorPages.serverError, ctx);
598
615
  return res.send(html);
@@ -602,7 +619,7 @@ function createApp(options = {}) {
602
619
  }
603
620
 
604
621
  // Default response
605
- if (req.accepts('html')) {
622
+ if (!preferJsonErrorResponse(req)) {
606
623
  res.send(default500Html(err, isDev));
607
624
  } else {
608
625
  res.json({