webspresso 0.0.90 → 0.0.93

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 (125) hide show
  1. package/README.md +98 -2509
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/seed.js +7 -1
  9. package/bin/commands/service.js +199 -0
  10. package/bin/commands/skill.js +7 -1
  11. package/bin/webspresso.js +4 -0
  12. package/core/auth/middleware.js +9 -0
  13. package/core/compileSchema.js +1 -5
  14. package/core/compression/compressible.js +63 -0
  15. package/core/compression/compression-stream.js +59 -0
  16. package/core/compression/index.js +25 -0
  17. package/core/compression/middleware.js +310 -0
  18. package/core/compression/negotiate.js +165 -0
  19. package/core/errors/base.js +31 -0
  20. package/core/errors/domain.js +193 -0
  21. package/core/errors/http.js +133 -0
  22. package/core/errors/index.js +77 -0
  23. package/core/errors/middleware.js +162 -0
  24. package/core/errors/normalize.js +171 -0
  25. package/core/kernel/app.js +2 -1
  26. package/core/kernel/index.js +5 -1
  27. package/core/orm/cache/fingerprint.js +23 -5
  28. package/core/orm/complexity.js +120 -0
  29. package/core/orm/eager-loader.js +54 -9
  30. package/core/orm/events.js +64 -14
  31. package/core/orm/index.js +83 -12
  32. package/core/orm/model.js +13 -2
  33. package/core/orm/query-builder.js +30 -4
  34. package/core/orm/repository.js +57 -25
  35. package/core/orm/transaction.js +39 -5
  36. package/core/orm/utils.js +6 -3
  37. package/core/queue/adapters/database.js +317 -0
  38. package/core/queue/adapters/memory.js +176 -0
  39. package/core/queue/adapters/redis.js +357 -0
  40. package/core/queue/index.js +21 -0
  41. package/core/queue/job.js +103 -0
  42. package/core/queue/manager.js +413 -0
  43. package/core/realtime/adapters/redis.js +431 -0
  44. package/core/realtime/adapters/socket-io.js +203 -0
  45. package/core/realtime/adapters/sse.js +210 -0
  46. package/core/realtime/adapters/websocket.js +337 -0
  47. package/core/realtime/auth-manager.js +140 -0
  48. package/core/realtime/core.js +359 -0
  49. package/core/realtime/emitter.js +98 -0
  50. package/core/realtime/errors.js +71 -0
  51. package/core/realtime/index.js +79 -0
  52. package/core/realtime/reconnect-manager.js +118 -0
  53. package/core/realtime/registry.js +243 -0
  54. package/core/shutdown/http-adapter.js +134 -0
  55. package/core/shutdown/index.js +12 -0
  56. package/core/shutdown/shutdown-manager.js +312 -0
  57. package/core/ssr/index.js +13 -0
  58. package/core/ssr/stream.js +174 -0
  59. package/core/validation/index.js +70 -0
  60. package/index.d.ts +924 -53
  61. package/index.js +110 -1
  62. package/package.json +30 -2
  63. package/plugins/admin-panel/api.js +37 -4
  64. package/plugins/admin-panel/app.js +57 -17
  65. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
  66. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  67. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
  68. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  69. package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
  70. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  71. package/plugins/admin-panel/core/admin-module.js +2 -2
  72. package/plugins/admin-panel/core/api-extensions.js +149 -0
  73. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  74. package/plugins/admin-panel/index.js +155 -23
  75. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  76. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  77. package/plugins/admin-panel/modules/dashboard.js +8 -1
  78. package/plugins/admin-panel/modules/menu.js +85 -0
  79. package/plugins/admin-panel/modules/user-management.js +5 -2
  80. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  81. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  82. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  83. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  84. package/plugins/basic-auth/index.js +293 -0
  85. package/plugins/data-exchange/export-xlsx.js +28 -2
  86. package/plugins/data-exchange/import.js +55 -16
  87. package/plugins/data-exchange/index.js +19 -0
  88. package/plugins/data-exchange/parse-table.js +14 -5
  89. package/plugins/data-exchange/record-selection.js +2 -0
  90. package/plugins/email/index.js +14 -1
  91. package/plugins/index.js +33 -0
  92. package/plugins/mcp/auth.js +104 -0
  93. package/plugins/mcp/discovery.js +88 -0
  94. package/plugins/mcp/index.js +208 -0
  95. package/plugins/mcp/prompts.js +149 -0
  96. package/plugins/mcp/providers/orm.js +325 -0
  97. package/plugins/mcp/providers/services.js +190 -0
  98. package/plugins/mcp/providers/system.js +87 -0
  99. package/plugins/mcp/server.js +472 -0
  100. package/plugins/mcp/transports/sse.js +139 -0
  101. package/plugins/mcp/transports/stdio.js +108 -0
  102. package/plugins/queue/index.js +108 -0
  103. package/plugins/realtime/index.js +150 -0
  104. package/plugins/recaptcha/index.js +11 -0
  105. package/plugins/redirect/index.js +9 -1
  106. package/plugins/rest-resources/index.js +280 -11
  107. package/plugins/upload/index.js +16 -0
  108. package/src/app-context.js +46 -6
  109. package/src/file-router.js +366 -130
  110. package/src/helpers.js +20 -2
  111. package/src/njk-frontmatter.js +24 -14
  112. package/src/plugin-manager.js +124 -4
  113. package/src/server.js +332 -54
  114. package/src/services/builtins/auth.js +273 -0
  115. package/src/services/builtins/exchange.js +229 -0
  116. package/src/services/builtins/mail.js +134 -0
  117. package/src/services/builtins/media.js +196 -0
  118. package/src/services/builtins/system.js +221 -0
  119. package/src/services/discovery.js +141 -0
  120. package/src/services/executor.js +292 -0
  121. package/src/services/index.js +70 -0
  122. package/src/services/memoize.js +256 -0
  123. package/src/services/registry.js +290 -0
  124. package/src/services/validator.js +230 -0
  125. package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
@@ -15,6 +15,7 @@ const {
15
15
  frontmatterToPatches,
16
16
  clearNjkFrontmatterCaches,
17
17
  } = require('./njk-frontmatter');
18
+ const { renderStream } = require('../core/ssr/stream');
18
19
 
19
20
  const NOOP = () => {};
20
21
  const EMPTY_OBJECT = Object.freeze({});
@@ -366,9 +367,31 @@ function scanDirectory(dir, baseDir = dir) {
366
367
  /**
367
368
  * Load i18n JSON file with caching
368
369
  * @param {string} filePath - Path to JSON file
370
+ * @param {boolean} [isDev=false] - Whether in development mode
369
371
  * @returns {Object} Parsed JSON or empty object
370
372
  */
371
- function loadI18nFile(filePath) {
373
+ function loadI18nFile(filePath, isDev = (process.env.NODE_ENV !== 'production')) {
374
+ if (!isDev) {
375
+ const cached = i18nCache.get(filePath);
376
+ if (cached) {
377
+ return cached.data;
378
+ }
379
+ if (!fs.existsSync(filePath)) {
380
+ i18nCache.set(filePath, { mtime: 0, data: EMPTY_OBJECT });
381
+ return EMPTY_OBJECT;
382
+ }
383
+ try {
384
+ const content = fs.readFileSync(filePath, 'utf-8');
385
+ const data = JSON.parse(content);
386
+ i18nCache.set(filePath, { mtime: 0, data });
387
+ return data;
388
+ } catch (err) {
389
+ console.error('Error loading i18n file:', filePath, err.message);
390
+ i18nCache.set(filePath, { mtime: 0, data: EMPTY_OBJECT });
391
+ return EMPTY_OBJECT;
392
+ }
393
+ }
394
+
372
395
  if (!fs.existsSync(filePath)) {
373
396
  return EMPTY_OBJECT;
374
397
  }
@@ -397,25 +420,43 @@ function loadI18nFile(filePath) {
397
420
  * @param {string} pagesDir - Pages directory path
398
421
  * @param {string} routeDir - Route directory path
399
422
  * @param {string} locale - Locale code
423
+ * @param {boolean} [isDev=false] - Whether in development mode
400
424
  * @returns {Object} Merged translations
401
425
  */
402
- function loadI18n(pagesDir, routeDir, locale) {
426
+ function loadI18n(pagesDir, routeDir, locale, isDev = (process.env.NODE_ENV !== 'production')) {
427
+ const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
428
+
429
+ if (!isDev) {
430
+ const mergedCached = mergedI18nCache.get(cacheKey);
431
+ if (mergedCached) {
432
+ return mergedCached.data;
433
+ }
434
+ }
435
+
403
436
  // Load global translations
404
437
  const globalPath = path.join(pagesDir, 'locales', `${locale}.json`);
405
- const globalTranslations = loadI18nFile(globalPath);
438
+ const globalTranslations = loadI18nFile(globalPath, isDev);
406
439
 
407
440
  // Load route-specific translations
408
441
  const routePath = path.join(routeDir, 'locales', `${locale}.json`);
409
- const routeTranslations = loadI18nFile(routePath);
442
+ const routeTranslations = loadI18nFile(routePath, isDev);
410
443
 
411
444
  const gEmpty = globalTranslations === EMPTY_OBJECT || !globalTranslations || Object.keys(globalTranslations).length === 0;
412
445
  const rEmpty = routeTranslations === EMPTY_OBJECT || !routeTranslations || Object.keys(routeTranslations).length === 0;
413
446
 
414
- if (gEmpty && rEmpty) return EMPTY_OBJECT;
415
- if (gEmpty) return routeTranslations;
416
- if (rEmpty) return globalTranslations;
447
+ if (gEmpty && rEmpty) {
448
+ if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: EMPTY_OBJECT });
449
+ return EMPTY_OBJECT;
450
+ }
451
+ if (gEmpty) {
452
+ if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: routeTranslations });
453
+ return routeTranslations;
454
+ }
455
+ if (rEmpty) {
456
+ if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: globalTranslations });
457
+ return globalTranslations;
458
+ }
417
459
 
418
- const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
419
460
  const gCached = i18nCache.get(globalPath);
420
461
  const rCached = i18nCache.get(routePath);
421
462
  const gm = gCached?.mtime ?? 0;
@@ -432,33 +473,103 @@ function loadI18n(pagesDir, routeDir, locale) {
432
473
  return data;
433
474
  }
434
475
 
435
- function createTranslator(translations = {}) {
436
- return function t(key, params = EMPTY_OBJECT, defaultVal = null) {
476
+ // Cache for Intl.PluralRules instances
477
+ const pluralRulesCache = new Map();
478
+
479
+ function getPluralRules(locale) {
480
+ let pr = pluralRulesCache.get(locale);
481
+ if (!pr) {
482
+ try {
483
+ pr = new Intl.PluralRules(locale);
484
+ } catch {
485
+ pr = new Intl.PluralRules('en');
486
+ }
487
+ if (pluralRulesCache.size < 100) {
488
+ pluralRulesCache.set(locale, pr);
489
+ }
490
+ }
491
+ return pr;
492
+ }
493
+
494
+ function resolveTranslationValue(dict, key) {
495
+ if (!dict || typeof dict !== 'object') return undefined;
496
+ if (dict[key] !== undefined) return dict[key];
497
+
498
+ const parts = key.split('.');
499
+ let curr = dict;
500
+ for (const part of parts) {
501
+ if (curr && typeof curr === 'object') {
502
+ curr = curr[part];
503
+ } else {
504
+ return undefined;
505
+ }
506
+ }
507
+ return curr;
508
+ }
509
+
510
+ /**
511
+ * Create a translation helper function with pluralization, fallback, and formatting support
512
+ * @param {Object} translations - Primary locale translations dictionary
513
+ * @param {Object|string} [options] - Options object or locale string
514
+ * @param {string} [options.locale='en'] - Active locale code
515
+ * @param {Object} [options.fallbackTranslations] - Fallback translations dictionary
516
+ * @param {string} [options.fallbackLocale='en'] - Fallback locale code
517
+ * @returns {Function} Translation function `t`
518
+ */
519
+ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
520
+ const opts = typeof options === 'string' ? { locale: options } : (options || EMPTY_OBJECT);
521
+ const locale = opts.locale || 'en';
522
+ const fallbackTranslations = opts.fallbackTranslations || null;
523
+
524
+ function t(key, params = EMPTY_OBJECT, defaultVal = null) {
437
525
  const defaultValue = typeof params === 'string' ? params : defaultVal;
438
526
  const interpolationParams = typeof params === 'object' && params !== null ? params : EMPTY_OBJECT;
439
527
 
440
- let value = translations ? translations[key] : undefined;
441
-
442
- if (value === undefined && translations) {
443
- // Try nested key lookup (e.g., "meta.title")
444
- const parts = key.split('.');
445
- value = translations;
446
- for (const part of parts) {
447
- if (value && typeof value === 'object') {
448
- value = value[part];
528
+ let value = resolveTranslationValue(translations, key);
529
+
530
+ // If missing in primary locale, fallback to fallback translations
531
+ if (value === undefined && fallbackTranslations) {
532
+ value = resolveTranslationValue(fallbackTranslations, key);
533
+ }
534
+
535
+ // Handle Pluralization if value is an object (or when count is passed)
536
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
537
+ const count = interpolationParams.count !== undefined
538
+ ? Number(interpolationParams.count)
539
+ : (interpolationParams.n !== undefined
540
+ ? Number(interpolationParams.n)
541
+ : (interpolationParams.cardinal !== undefined ? Number(interpolationParams.cardinal) : undefined));
542
+
543
+ if (count !== undefined && !Number.isNaN(count)) {
544
+ const countStr = String(count);
545
+ if (typeof value[countStr] === 'string') {
546
+ value = value[countStr];
547
+ } else if (count === 0 && typeof value.zero === 'string') {
548
+ value = value.zero;
449
549
  } else {
450
- value = undefined;
451
- break;
550
+ const category = getPluralRules(locale).select(count);
551
+ if (typeof value[category] === 'string') {
552
+ value = value[category];
553
+ } else if (typeof value.other === 'string') {
554
+ value = value.other;
555
+ } else {
556
+ const firstString = Object.values(value).find((v) => typeof v === 'string');
557
+ value = firstString !== undefined ? firstString : key;
558
+ }
452
559
  }
453
560
  }
454
561
  }
455
-
562
+
456
563
  if (value === undefined) {
457
564
  return defaultValue !== null && defaultValue !== undefined ? defaultValue : key;
458
565
  }
459
-
566
+
567
+ if (typeof value !== 'string') {
568
+ return value;
569
+ }
570
+
460
571
  // Replace params like {{name}} in the translation
461
- if (typeof value === 'string' && interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
572
+ if (interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
462
573
  for (const [paramKey, paramValue] of Object.entries(interpolationParams)) {
463
574
  let regex = paramRegexCache.get(paramKey);
464
575
  if (regex === undefined) {
@@ -468,12 +579,84 @@ function createTranslator(translations = {}) {
468
579
  paramRegexCache.set(paramKey, regex);
469
580
  }
470
581
  }
471
- value = value.replace(regex, paramValue);
582
+ value = value.replace(regex, () => String(paramValue));
472
583
  }
473
584
  }
474
-
585
+
475
586
  return value;
587
+ }
588
+
589
+ // Attach metadata & native formatting helpers to `t`
590
+ t.locale = locale;
591
+ t.translations = translations;
592
+
593
+ t.number = function formatNumber(num, numOpts) {
594
+ if (num === null || num === undefined || Number.isNaN(Number(num))) return '';
595
+ try {
596
+ return new Intl.NumberFormat(locale, numOpts).format(Number(num));
597
+ } catch {
598
+ return String(num);
599
+ }
476
600
  };
601
+ t.formatNumber = t.number;
602
+
603
+ t.currency = function formatCurrency(amount, currency = 'USD', currOpts = {}) {
604
+ if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '';
605
+ try {
606
+ return new Intl.NumberFormat(locale, { style: 'currency', currency, ...currOpts }).format(Number(amount));
607
+ } catch {
608
+ return `${amount} ${currency}`;
609
+ }
610
+ };
611
+ t.formatCurrency = t.currency;
612
+
613
+ t.date = function formatDate(date, dateOpts = { dateStyle: 'medium' }) {
614
+ if (!date) return '';
615
+ try {
616
+ const d = date instanceof Date ? date : new Date(date);
617
+ if (Number.isNaN(d.getTime())) return '';
618
+ return new Intl.DateTimeFormat(locale, dateOpts).format(d);
619
+ } catch {
620
+ return String(date);
621
+ }
622
+ };
623
+ t.formatDate = t.date;
624
+
625
+ t.relativeTime = function formatRelativeTime(val, unit = 'day', relOpts = { numeric: 'auto' }) {
626
+ if (val === null || val === undefined || Number.isNaN(Number(val))) return '';
627
+ try {
628
+ return new Intl.RelativeTimeFormat(locale, relOpts).format(Number(val), unit);
629
+ } catch {
630
+ return `${val} ${unit}`;
631
+ }
632
+ };
633
+ t.formatRelativeTime = t.relativeTime;
634
+
635
+ t.plural = function inlinePlural(count, forms, params = {}) {
636
+ if (!forms || typeof forms !== 'object') return '';
637
+ const num = Number(count);
638
+ const countStr = String(num);
639
+ let template = forms[countStr];
640
+ if (typeof template !== 'string') {
641
+ if (num === 0 && typeof forms.zero === 'string') {
642
+ template = forms.zero;
643
+ } else {
644
+ const category = getPluralRules(locale).select(num);
645
+ template = forms[category] || forms.other || Object.values(forms)[0] || '';
646
+ }
647
+ }
648
+ const allParams = { count: num, ...params };
649
+ return template.replace(/{{\s*(\w+)\s*}}/g, (_, k) => String(allParams[k] ?? ''));
650
+ };
651
+
652
+ t.has = function hasKey(key) {
653
+ if (resolveTranslationValue(translations, key) !== undefined) return true;
654
+ if (fallbackTranslations && resolveTranslationValue(fallbackTranslations, key) !== undefined) return true;
655
+ return false;
656
+ };
657
+ t.exists = t.has;
658
+
659
+ return t;
477
660
  }
478
661
 
479
662
  /**
@@ -483,32 +666,41 @@ function createTranslator(translations = {}) {
483
666
  * @returns {Object|null} Route config or null
484
667
  */
485
668
  function loadRouteConfig(configPath, isDev) {
669
+ if (!isDev) {
670
+ if (configCache.has(configPath)) {
671
+ return configCache.get(configPath);
672
+ }
673
+ if (!fs.existsSync(configPath)) {
674
+ configCache.set(configPath, null);
675
+ return null;
676
+ }
677
+ try {
678
+ const config = require(configPath);
679
+ configCache.set(configPath, config);
680
+ return config;
681
+ } catch (err) {
682
+ console.error('Error loading route config:', configPath, err.message);
683
+ configCache.set(configPath, null);
684
+ return null;
685
+ }
686
+ }
687
+
486
688
  if (!fs.existsSync(configPath)) {
487
689
  routeConfigDevCache.delete(configPath);
488
690
  return null;
489
691
  }
490
692
 
491
693
  try {
492
- if (isDev) {
493
- const stats = fs.statSync(configPath);
494
- const devCached = routeConfigDevCache.get(configPath);
495
- if (devCached && devCached.mtime >= stats.mtimeMs) {
496
- return devCached.config;
497
- }
498
- if (require.cache[require.resolve(configPath)]) {
499
- delete require.cache[require.resolve(configPath)];
500
- }
501
- const config = require(configPath);
502
- routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
503
- return config;
694
+ const stats = fs.statSync(configPath);
695
+ const devCached = routeConfigDevCache.get(configPath);
696
+ if (devCached && devCached.mtime >= stats.mtimeMs) {
697
+ return devCached.config;
504
698
  }
505
-
506
- if (configCache.has(configPath)) {
507
- return configCache.get(configPath);
699
+ if (require.cache[require.resolve(configPath)]) {
700
+ delete require.cache[require.resolve(configPath)];
508
701
  }
509
-
510
702
  const config = require(configPath);
511
- configCache.set(configPath, config);
703
+ routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
512
704
  return config;
513
705
  } catch (err) {
514
706
  console.error('Error loading route config:', configPath, err.message);
@@ -677,6 +869,7 @@ function mountPages(app, options) {
677
869
  db = null,
678
870
  clientRuntime: clientRuntimeOpt = null,
679
871
  pageAssets: pageAssetsOpt = null,
872
+ serviceRegistry = null,
680
873
  } = options;
681
874
  const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
682
875
  const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
@@ -832,113 +1025,136 @@ function mountPages(app, options) {
832
1025
  /** Register API routes (shared by static phase and dynamic phase). */
833
1026
  const registerApiRoutes = (routes) => {
834
1027
  for (const route of routes) {
835
- const handler = require(route.fullPath);
836
- const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
837
- const routeMiddleware = handler.middleware;
1028
+ const handler = require(route.fullPath);
1029
+ const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
1030
+ const routeMiddleware = handler.middleware;
838
1031
 
839
- const preResolvedMw = routeMiddleware
840
- ? resolveMiddlewares(routeMiddleware, middlewares)
841
- : EMPTY_ARRAY;
842
-
843
- if (typeof handlerFn !== 'function') {
844
- console.warn(`API route ${route.file} does not export a function`);
845
- continue;
846
- }
1032
+ const preResolvedMw = routeMiddleware
1033
+ ? resolveMiddlewares(routeMiddleware, middlewares)
1034
+ : EMPTY_ARRAY;
847
1035
 
848
- app[route.method](route.routePath, async (req, res, next) => {
849
- try {
850
- // Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
851
- if (db != null) {
852
- req.db = db;
853
- }
1036
+ if (typeof handlerFn !== 'function') {
1037
+ console.warn(`API route ${route.file} does not export a function`);
1038
+ continue;
1039
+ }
854
1040
 
855
- // Reload handler in dev mode
856
- if (isDev && require.cache[require.resolve(route.fullPath)]) {
857
- delete require.cache[require.resolve(route.fullPath)];
858
- }
859
- const currentHandler = isDev
860
- ? require(route.fullPath)
861
- : handler;
862
- const fn = typeof currentHandler === 'function'
863
- ? currentHandler
864
- : currentHandler.default || currentHandler.handler;
865
-
866
- if (isDev) {
867
- invalidateSchema(route.fullPath);
868
- }
869
- let compiledSchema;
1041
+ let preCompiledSchema = null;
1042
+ if (!isDev) {
870
1043
  try {
871
- compiledSchema = compileSchema(route.fullPath, currentHandler);
1044
+ preCompiledSchema = compileSchema(route.fullPath, handler);
872
1045
  } catch (schemaErr) {
873
1046
  console.error(`API schema compile error ${route.routePath}:`, schemaErr);
874
- res.status(500).json({ error: 'Internal Server Error', message: schemaErr.message });
875
- return;
876
1047
  }
877
-
1048
+ }
1049
+
1050
+ app[route.method](route.routePath, async (req, res, next) => {
878
1051
  try {
879
- applySchema(req, compiledSchema);
880
- } catch (err) {
881
- if (err instanceof ZodError) {
882
- return res.status(400).json({
883
- error: 'Validation Error',
884
- issues: err.issues,
885
- });
1052
+ // Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
1053
+ if (db != null) {
1054
+ req.db = db;
886
1055
  }
887
- throw err;
888
- }
889
-
890
- // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
891
- if (preResolvedMw.length) {
892
- for (const mw of preResolvedMw) {
893
- await new Promise((resolve, reject) => {
894
- mw(req, res, (err) => {
895
- if (err) reject(err);
896
- else resolve();
1056
+ if (serviceRegistry != null) {
1057
+ req.service = (name, input, opts) =>
1058
+ serviceRegistry.call(name, input, { req, res, db }, opts);
1059
+ }
1060
+
1061
+ let fn = handlerFn;
1062
+ let compiledSchema = preCompiledSchema;
1063
+
1064
+ // Reload handler and schema in dev mode
1065
+ if (isDev) {
1066
+ if (require.cache[require.resolve(route.fullPath)]) {
1067
+ delete require.cache[require.resolve(route.fullPath)];
1068
+ }
1069
+ const currentHandler = require(route.fullPath);
1070
+ fn = typeof currentHandler === 'function'
1071
+ ? currentHandler
1072
+ : currentHandler.default || currentHandler.handler;
1073
+
1074
+ invalidateSchema(route.fullPath);
1075
+ try {
1076
+ compiledSchema = compileSchema(route.fullPath, currentHandler);
1077
+ } catch (schemaErr) {
1078
+ console.error(`API schema compile error ${route.routePath}:`, schemaErr);
1079
+ res.status(500).json({ error: 'Internal Server Error', message: schemaErr.message });
1080
+ return;
1081
+ }
1082
+ }
1083
+
1084
+ try {
1085
+ applySchema(req, compiledSchema);
1086
+ } catch (err) {
1087
+ if (err instanceof ZodError) {
1088
+ return res.status(400).json({
1089
+ error: 'Validation Error',
1090
+ issues: err.issues,
897
1091
  });
898
- });
1092
+ }
1093
+ throw err;
899
1094
  }
1095
+
1096
+ // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
1097
+ if (preResolvedMw.length) {
1098
+ for (const mw of preResolvedMw) {
1099
+ await new Promise((resolve, reject) => {
1100
+ mw(req, res, (err) => {
1101
+ if (err) reject(err);
1102
+ else resolve();
1103
+ });
1104
+ });
1105
+ }
1106
+ }
1107
+
1108
+ await fn(req, res, next);
1109
+ } catch (err) {
1110
+ console.error(`API error ${route.routePath}:`, err);
1111
+ const hookCtx = { req, res, error: err };
1112
+ try {
1113
+ await executeHook(globalHooks, 'onError', hookCtx, err);
1114
+ } catch (hookErr) {
1115
+ console.error('Error in onError hook:', hookErr);
1116
+ }
1117
+ return next(err);
900
1118
  }
901
-
902
- await fn(req, res, next);
903
- } catch (err) {
904
- console.error(`API error ${route.routePath}:`, err);
905
- const hookCtx = { req, res, error: err };
906
- try {
907
- await executeHook(globalHooks, 'onError', hookCtx, err);
908
- } catch (hookErr) {
909
- console.error('Error in onError hook:', hookErr);
910
- }
911
- return next(err);
912
- }
913
- });
914
-
915
- log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
1119
+ });
1120
+
1121
+ log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
916
1122
  }
917
1123
  };
918
1124
 
919
1125
  /** Register SSR GET routes (shared by static phase and dynamic phase). */
920
1126
  const registerSsrRoutes = (routes) => {
921
1127
  for (const route of routes) {
922
- const mountConfig = loadRouteConfig(route.configPath, isDev);
923
- const preResolvedPageMw = mountConfig?.middleware
924
- ? resolveMiddlewares(mountConfig.middleware, middlewares)
925
- : EMPTY_ARRAY;
926
-
927
- app.get(route.routePath, async (req, res, next) => {
928
- try {
929
- // Detect locale
930
- const locale = detectLocale(req);
931
-
932
- // Load translations
933
- const translations = loadI18n(absolutePagesDir, route.routeDir, locale);
934
- const t = createTranslator(translations);
935
-
936
- // Load route config
937
- const config = loadRouteConfig(route.configPath, isDev);
938
- const routeHooks = config?.hooks || EMPTY_OBJECT;
1128
+ const mountConfig = loadRouteConfig(route.configPath, isDev);
1129
+ const preResolvedPageMw = mountConfig?.middleware
1130
+ ? resolveMiddlewares(mountConfig.middleware, middlewares)
1131
+ : EMPTY_ARRAY;
1132
+ const staticRouteHooks = mountConfig?.hooks || EMPTY_OBJECT;
1133
+
1134
+ app.get(route.routePath, async (req, res, next) => {
1135
+ try {
1136
+ // Detect locale
1137
+ const locale = detectLocale(req);
1138
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
1139
+
1140
+ // Load translations (primary + fallback)
1141
+ const translations = loadI18n(absolutePagesDir, route.routeDir, locale, isDev);
1142
+ const fallbackTranslations = (locale !== defaultLocale)
1143
+ ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, isDev)
1144
+ : EMPTY_OBJECT;
1145
+
1146
+ const t = createTranslator(translations, {
1147
+ locale,
1148
+ fallbackTranslations,
1149
+ fallbackLocale: defaultLocale,
1150
+ });
1151
+
1152
+ // Load route config
1153
+ const config = isDev ? loadRouteConfig(route.configPath, true) : mountConfig;
1154
+ const routeHooks = isDev ? (config?.hooks || EMPTY_OBJECT) : staticRouteHooks;
939
1155
 
940
1156
  // Create context with plugin helpers merged
941
- const baseHelpers = createHelpers({ req, res, locale });
1157
+ const baseHelpers = createHelpers({ req, res, locale, t });
942
1158
  const pluginHelpers = pluginManager ? pluginManager.getHelpers() : EMPTY_OBJECT;
943
1159
  if (pluginManager) {
944
1160
  const contentApi = pluginManager.getPluginAPI('content');
@@ -959,6 +1175,12 @@ function mountPages(app, options) {
959
1175
  routeDir: route.routeDir,
960
1176
  locale,
961
1177
  t,
1178
+ service: (name, input, opts) => {
1179
+ if (serviceRegistry) {
1180
+ return serviceRegistry.call(name, input, ctx, opts);
1181
+ }
1182
+ throw new Error(`Services not configured. Cannot call service '${name}'.`);
1183
+ },
962
1184
  data: { ...njkTpl.dataPatch },
963
1185
  meta: {
964
1186
  title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
@@ -971,6 +1193,8 @@ function mountPages(app, options) {
971
1193
  clientRuntime,
972
1194
  };
973
1195
 
1196
+ req.context = ctx;
1197
+
974
1198
  // Execute hooks: onRequest
975
1199
  await executeHook(globalHooks, 'onRequest', ctx);
976
1200
  await executeHook(routeHooks, 'onRequest', ctx);
@@ -1055,6 +1279,18 @@ function mountPages(app, options) {
1055
1279
 
1056
1280
  // Render the template
1057
1281
  const templatePath = route.file.split(path.sep).join('/');
1282
+
1283
+ // SSR Streaming mode (chunked transfer)
1284
+ if (config?.stream || ctx.data?.__stream || ctx.defer) {
1285
+ const streamDefer = ctx.defer || ctx.data?.__defer || {};
1286
+ const status = (route.routePath === '/404' || route.file === '404.njk') ? 404 : 200;
1287
+ return await renderStream(res, templatePath, renderContext, {
1288
+ env: nunjucks,
1289
+ defer: streamDefer,
1290
+ status,
1291
+ });
1292
+ }
1293
+
1058
1294
  let html =
1059
1295
  njkTpl.useStringRender && njkTpl.templateBody != null
1060
1296
  ? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
package/src/helpers.js CHANGED
@@ -140,7 +140,7 @@ class AssetManager {
140
140
  */
141
141
  buildAttributes(attrs) {
142
142
  return Object.entries(attrs)
143
- .filter(([_, v]) => v !== undefined && v !== null && v !== false)
143
+ .filter(([k, v]) => v !== undefined && v !== null && v !== false && /^[a-zA-Z0-9_\-:]+$/.test(k))
144
144
  .map(([k, v]) => v === true ? k : `${k}="${this.escapeHtml(String(v))}"`)
145
145
  .join(' ');
146
146
  }
@@ -186,9 +186,27 @@ function getAssetManager() {
186
186
  * @returns {Object} fsy helper object
187
187
  */
188
188
  function createHelpers(ctx) {
189
- const { req, res, baseUrl = process.env.BASE_URL || 'http://localhost:3000' } = ctx;
189
+ const { req, res, baseUrl = process.env.BASE_URL || 'http://localhost:3000', locale = 'en', t = null } = ctx || {};
190
190
 
191
191
  return {
192
+ /** Active request locale */
193
+ locale,
194
+
195
+ /** Active i18n translator function */
196
+ t,
197
+
198
+ /**
199
+ * Generate URL for switching to a target locale with current query parameters preserved
200
+ * @param {string} targetLocale - Locale code to switch to (e.g. 'en', 'tr')
201
+ * @param {string} [customPath] - Optional custom path (defaults to current req.path)
202
+ * @returns {string} URL string with updated locale query param
203
+ */
204
+ localeUrl(targetLocale, customPath) {
205
+ const p = customPath || (req && req.path ? req.path : '/');
206
+ const q = { ...(req && req.query ? req.query : {}), lang: targetLocale };
207
+ return this.url(p, q);
208
+ },
209
+
192
210
  /**
193
211
  * Build a URL path with optional query parameters
194
212
  * @param {string} path - URL path