webspresso 0.0.89 → 0.0.91

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 (102) hide show
  1. package/README.md +450 -53
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/manager.js +36 -26
  5. package/core/auth/middleware.js +21 -2
  6. package/core/compression/compressible.js +51 -0
  7. package/core/compression/compression-stream.js +59 -0
  8. package/core/compression/index.js +25 -0
  9. package/core/compression/middleware.js +301 -0
  10. package/core/compression/negotiate.js +153 -0
  11. package/core/errors/base.js +31 -0
  12. package/core/errors/domain.js +193 -0
  13. package/core/errors/http.js +133 -0
  14. package/core/errors/index.js +77 -0
  15. package/core/errors/middleware.js +162 -0
  16. package/core/errors/normalize.js +171 -0
  17. package/core/orm/complexity.js +120 -0
  18. package/core/orm/events.js +64 -14
  19. package/core/orm/index.js +45 -10
  20. package/core/orm/model.js +10 -2
  21. package/core/orm/query-builder.js +30 -4
  22. package/core/orm/repository.js +57 -25
  23. package/core/orm/scopes.js +2 -1
  24. package/core/orm/transaction.js +39 -5
  25. package/core/orm/utils.js +6 -3
  26. package/core/queue/adapters/database.js +284 -0
  27. package/core/queue/adapters/memory.js +155 -0
  28. package/core/queue/adapters/redis.js +336 -0
  29. package/core/queue/index.js +21 -0
  30. package/core/queue/job.js +103 -0
  31. package/core/queue/manager.js +387 -0
  32. package/core/realtime/adapters/redis.js +431 -0
  33. package/core/realtime/adapters/socket-io.js +203 -0
  34. package/core/realtime/adapters/sse.js +210 -0
  35. package/core/realtime/adapters/websocket.js +337 -0
  36. package/core/realtime/auth-manager.js +140 -0
  37. package/core/realtime/core.js +359 -0
  38. package/core/realtime/emitter.js +98 -0
  39. package/core/realtime/errors.js +71 -0
  40. package/core/realtime/index.js +79 -0
  41. package/core/realtime/reconnect-manager.js +118 -0
  42. package/core/realtime/registry.js +243 -0
  43. package/core/shutdown/http-adapter.js +134 -0
  44. package/core/shutdown/index.js +12 -0
  45. package/core/shutdown/shutdown-manager.js +312 -0
  46. package/core/ssr/index.js +13 -0
  47. package/core/ssr/stream.js +174 -0
  48. package/index.d.ts +829 -52
  49. package/index.js +103 -0
  50. package/package.json +22 -2
  51. package/plugins/admin-panel/api.js +37 -4
  52. package/plugins/admin-panel/app.js +67 -17
  53. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  54. package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
  55. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  56. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  57. package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
  58. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  59. package/plugins/admin-panel/core/admin-module.js +2 -2
  60. package/plugins/admin-panel/core/api-extensions.js +149 -0
  61. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  62. package/plugins/admin-panel/index.js +131 -18
  63. package/plugins/admin-panel/modules/admin-users.js +770 -0
  64. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  65. package/plugins/admin-panel/modules/menu.js +85 -0
  66. package/plugins/admin-panel/modules/user-management.js +5 -2
  67. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  68. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  69. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  70. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  71. package/plugins/basic-auth/index.js +293 -0
  72. package/plugins/content/client/inline-edit.js +41 -17
  73. package/plugins/cors.js +12 -8
  74. package/plugins/csrf/index.js +8 -8
  75. package/plugins/data-exchange/export-xlsx.js +5 -1
  76. package/plugins/data-exchange/import.js +16 -2
  77. package/plugins/data-exchange/index.js +14 -0
  78. package/plugins/email/index.js +14 -1
  79. package/plugins/index.js +29 -0
  80. package/plugins/queue/index.js +108 -0
  81. package/plugins/realtime/index.js +150 -0
  82. package/plugins/recaptcha/index.js +11 -0
  83. package/plugins/redirect/index.js +9 -1
  84. package/plugins/rest-resources/index.js +280 -11
  85. package/plugins/upload/index.js +16 -0
  86. package/src/app-context.js +46 -6
  87. package/src/file-router.js +220 -28
  88. package/src/helpers.js +24 -5
  89. package/src/plugin-manager.js +134 -7
  90. package/src/server.js +470 -44
  91. package/src/services/builtins/auth.js +273 -0
  92. package/src/services/builtins/exchange.js +229 -0
  93. package/src/services/builtins/mail.js +134 -0
  94. package/src/services/builtins/media.js +196 -0
  95. package/src/services/builtins/system.js +221 -0
  96. package/src/services/discovery.js +140 -0
  97. package/src/services/executor.js +292 -0
  98. package/src/services/index.js +70 -0
  99. package/src/services/memoize.js +256 -0
  100. package/src/services/registry.js +288 -0
  101. package/src/services/validator.js +281 -0
  102. package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
@@ -3,21 +3,23 @@
3
3
  * @module src/app-context
4
4
  */
5
5
 
6
- /** @type {{ db: object|null }} */
6
+ /** @type {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null }} */
7
7
  let context = {
8
8
  db: null,
9
+ shutdownManager: null,
10
+ serviceRegistry: null,
9
11
  };
10
12
 
11
13
  /**
12
14
  * Merge into the current context (typically called once from createApp).
13
- * @param {{ db?: object|null }} partial
15
+ * @param {{ db?: object|null, shutdownManager?: object|null, serviceRegistry?: object|null }} partial
14
16
  */
15
17
  function setAppContext(partial) {
16
18
  context = { ...context, ...partial };
17
19
  }
18
20
 
19
21
  /**
20
- * @returns {{ db: object|null }}
22
+ * @returns {{ db: object|null, shutdownManager: object|null, serviceRegistry: object|null }}
21
23
  */
22
24
  function getAppContext() {
23
25
  return context;
@@ -45,23 +47,57 @@ function hasDb() {
45
47
  return context.db != null;
46
48
  }
47
49
 
50
+ /**
51
+ * @returns {object|null}
52
+ */
53
+ function getShutdownManager() {
54
+ return context.shutdownManager;
55
+ }
56
+
57
+ /**
58
+ * @returns {boolean}
59
+ */
60
+ function hasShutdownManager() {
61
+ return context.shutdownManager != null;
62
+ }
63
+
64
+ /**
65
+ * Get the registered ServiceRegistry instance
66
+ * @returns {object|null}
67
+ */
68
+ function getServiceRegistry() {
69
+ return context.serviceRegistry;
70
+ }
71
+
72
+ /**
73
+ * Check if a ServiceRegistry is registered
74
+ * @returns {boolean}
75
+ */
76
+ function hasServiceRegistry() {
77
+ return context.serviceRegistry != null;
78
+ }
79
+
48
80
  /**
49
81
  * Clear context (e.g. between tests).
50
82
  */
51
83
  function resetAppContext() {
52
- context = { db: null };
84
+ context = { db: null, shutdownManager: null, serviceRegistry: null };
53
85
  }
54
86
 
55
87
  /**
56
- * Express middleware: sets `req.db` from registered app context.
88
+ * Express middleware: sets `req.db` and `req.service` from registered app context.
57
89
  * File-based `pages/api/*` routes attach this automatically; use in `setupRoutes`
58
- * for manually registered handlers that need `req.db`.
90
+ * for manually registered handlers that need `req.db` and `req.service`.
59
91
  * @type {import('express').RequestHandler}
60
92
  */
61
93
  function attachDbMiddleware(req, res, next) {
62
94
  if (context.db != null) {
63
95
  req.db = context.db;
64
96
  }
97
+ if (context.serviceRegistry != null) {
98
+ req.service = (name, input, opts) =>
99
+ context.serviceRegistry.call(name, input, req.context || { req, res, db: context.db }, opts);
100
+ }
65
101
  next();
66
102
  }
67
103
 
@@ -70,6 +106,10 @@ module.exports = {
70
106
  getAppContext,
71
107
  getDb,
72
108
  hasDb,
109
+ getShutdownManager,
110
+ hasShutdownManager,
111
+ getServiceRegistry,
112
+ hasServiceRegistry,
73
113
  resetAppContext,
74
114
  attachDbMiddleware,
75
115
  };
@@ -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({});
@@ -432,36 +433,104 @@ function loadI18n(pagesDir, routeDir, locale) {
432
433
  return data;
433
434
  }
434
435
 
436
+ // Cache for Intl.PluralRules instances
437
+ const pluralRulesCache = new Map();
438
+
439
+ function getPluralRules(locale) {
440
+ let pr = pluralRulesCache.get(locale);
441
+ if (!pr) {
442
+ try {
443
+ pr = new Intl.PluralRules(locale);
444
+ } catch {
445
+ pr = new Intl.PluralRules('en');
446
+ }
447
+ if (pluralRulesCache.size < 100) {
448
+ pluralRulesCache.set(locale, pr);
449
+ }
450
+ }
451
+ return pr;
452
+ }
453
+
454
+ function resolveTranslationValue(dict, key) {
455
+ if (!dict || typeof dict !== 'object') return undefined;
456
+ if (dict[key] !== undefined) return dict[key];
457
+
458
+ const parts = key.split('.');
459
+ let curr = dict;
460
+ for (const part of parts) {
461
+ if (curr && typeof curr === 'object') {
462
+ curr = curr[part];
463
+ } else {
464
+ return undefined;
465
+ }
466
+ }
467
+ return curr;
468
+ }
469
+
435
470
  /**
436
- * Create a translation function
437
- * @param {Object} translations - Translation object
438
- * @returns {Function} Translation function t(key)
471
+ * Create a translation helper function with pluralization, fallback, and formatting support
472
+ * @param {Object} translations - Primary locale translations dictionary
473
+ * @param {Object|string} [options] - Options object or locale string
474
+ * @param {string} [options.locale='en'] - Active locale code
475
+ * @param {Object} [options.fallbackTranslations] - Fallback translations dictionary
476
+ * @param {string} [options.fallbackLocale='en'] - Fallback locale code
477
+ * @returns {Function} Translation function `t`
439
478
  */
440
- function createTranslator(translations) {
441
- return function t(key, params = EMPTY_OBJECT) {
442
- let value = translations[key];
443
-
444
- if (value === undefined) {
445
- // Try nested key lookup (e.g., "meta.title")
446
- const parts = key.split('.');
447
- value = translations;
448
- for (const part of parts) {
449
- if (value && typeof value === 'object') {
450
- value = value[part];
479
+ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
480
+ const opts = typeof options === 'string' ? { locale: options } : (options || EMPTY_OBJECT);
481
+ const locale = opts.locale || 'en';
482
+ const fallbackTranslations = opts.fallbackTranslations || null;
483
+
484
+ function t(key, params = EMPTY_OBJECT, defaultVal = null) {
485
+ const defaultValue = typeof params === 'string' ? params : defaultVal;
486
+ const interpolationParams = typeof params === 'object' && params !== null ? params : EMPTY_OBJECT;
487
+
488
+ let value = resolveTranslationValue(translations, key);
489
+
490
+ // If missing in primary locale, fallback to fallback translations
491
+ if (value === undefined && fallbackTranslations) {
492
+ value = resolveTranslationValue(fallbackTranslations, key);
493
+ }
494
+
495
+ // Handle Pluralization if value is an object (or when count is passed)
496
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
497
+ const count = interpolationParams.count !== undefined
498
+ ? Number(interpolationParams.count)
499
+ : (interpolationParams.n !== undefined
500
+ ? Number(interpolationParams.n)
501
+ : (interpolationParams.cardinal !== undefined ? Number(interpolationParams.cardinal) : undefined));
502
+
503
+ if (count !== undefined && !Number.isNaN(count)) {
504
+ const countStr = String(count);
505
+ if (typeof value[countStr] === 'string') {
506
+ value = value[countStr];
507
+ } else if (count === 0 && typeof value.zero === 'string') {
508
+ value = value.zero;
451
509
  } else {
452
- value = undefined;
453
- break;
510
+ const category = getPluralRules(locale).select(count);
511
+ if (typeof value[category] === 'string') {
512
+ value = value[category];
513
+ } else if (typeof value.other === 'string') {
514
+ value = value.other;
515
+ } else {
516
+ const firstString = Object.values(value).find((v) => typeof v === 'string');
517
+ value = firstString !== undefined ? firstString : key;
518
+ }
454
519
  }
455
520
  }
456
521
  }
457
-
522
+
458
523
  if (value === undefined) {
459
- return key; // Return key if translation not found
524
+ return defaultValue !== null && defaultValue !== undefined ? defaultValue : key;
460
525
  }
461
-
526
+
527
+ if (typeof value !== 'string') {
528
+ return value;
529
+ }
530
+
462
531
  // Replace params like {{name}} in the translation
463
- if (typeof value === 'string' && params && params !== EMPTY_OBJECT && Object.keys(params).length > 0) {
464
- for (const [paramKey, paramValue] of Object.entries(params)) {
532
+ if (interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
533
+ for (const [paramKey, paramValue] of Object.entries(interpolationParams)) {
465
534
  let regex = paramRegexCache.get(paramKey);
466
535
  if (regex === undefined) {
467
536
  const escaped = escapeRegExp(paramKey);
@@ -470,12 +539,84 @@ function createTranslator(translations) {
470
539
  paramRegexCache.set(paramKey, regex);
471
540
  }
472
541
  }
473
- value = value.replace(regex, paramValue);
542
+ value = value.replace(regex, () => String(paramValue));
474
543
  }
475
544
  }
476
-
545
+
477
546
  return value;
547
+ }
548
+
549
+ // Attach metadata & native formatting helpers to `t`
550
+ t.locale = locale;
551
+ t.translations = translations;
552
+
553
+ t.number = function formatNumber(num, numOpts) {
554
+ if (num === null || num === undefined || Number.isNaN(Number(num))) return '';
555
+ try {
556
+ return new Intl.NumberFormat(locale, numOpts).format(Number(num));
557
+ } catch {
558
+ return String(num);
559
+ }
560
+ };
561
+ t.formatNumber = t.number;
562
+
563
+ t.currency = function formatCurrency(amount, currency = 'USD', currOpts = {}) {
564
+ if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '';
565
+ try {
566
+ return new Intl.NumberFormat(locale, { style: 'currency', currency, ...currOpts }).format(Number(amount));
567
+ } catch {
568
+ return `${amount} ${currency}`;
569
+ }
570
+ };
571
+ t.formatCurrency = t.currency;
572
+
573
+ t.date = function formatDate(date, dateOpts = { dateStyle: 'medium' }) {
574
+ if (!date) return '';
575
+ try {
576
+ const d = date instanceof Date ? date : new Date(date);
577
+ if (Number.isNaN(d.getTime())) return '';
578
+ return new Intl.DateTimeFormat(locale, dateOpts).format(d);
579
+ } catch {
580
+ return String(date);
581
+ }
478
582
  };
583
+ t.formatDate = t.date;
584
+
585
+ t.relativeTime = function formatRelativeTime(val, unit = 'day', relOpts = { numeric: 'auto' }) {
586
+ if (val === null || val === undefined || Number.isNaN(Number(val))) return '';
587
+ try {
588
+ return new Intl.RelativeTimeFormat(locale, relOpts).format(Number(val), unit);
589
+ } catch {
590
+ return `${val} ${unit}`;
591
+ }
592
+ };
593
+ t.formatRelativeTime = t.relativeTime;
594
+
595
+ t.plural = function inlinePlural(count, forms, params = {}) {
596
+ if (!forms || typeof forms !== 'object') return '';
597
+ const num = Number(count);
598
+ const countStr = String(num);
599
+ let template = forms[countStr];
600
+ if (typeof template !== 'string') {
601
+ if (num === 0 && typeof forms.zero === 'string') {
602
+ template = forms.zero;
603
+ } else {
604
+ const category = getPluralRules(locale).select(num);
605
+ template = forms[category] || forms.other || Object.values(forms)[0] || '';
606
+ }
607
+ }
608
+ const allParams = { count: num, ...params };
609
+ return template.replace(/{{\s*(\w+)\s*}}/g, (_, k) => String(allParams[k] ?? ''));
610
+ };
611
+
612
+ t.has = function hasKey(key) {
613
+ if (resolveTranslationValue(translations, key) !== undefined) return true;
614
+ if (fallbackTranslations && resolveTranslationValue(fallbackTranslations, key) !== undefined) return true;
615
+ return false;
616
+ };
617
+ t.exists = t.has;
618
+
619
+ return t;
479
620
  }
480
621
 
481
622
  /**
@@ -679,6 +820,7 @@ function mountPages(app, options) {
679
820
  db = null,
680
821
  clientRuntime: clientRuntimeOpt = null,
681
822
  pageAssets: pageAssetsOpt = null,
823
+ serviceRegistry = null,
682
824
  } = options;
683
825
  const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
684
826
  const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
@@ -814,6 +956,18 @@ function mountPages(app, options) {
814
956
  sortRoutes(apiRoutes);
815
957
  sortRoutes(ssrRoutes);
816
958
 
959
+ // Check for duplicate route bindings
960
+ const registeredRouteMap = new Map();
961
+ for (const r of [...apiRoutes, ...ssrRoutes]) {
962
+ const key = `${(r.method || 'GET').toUpperCase()} ${r.routePath}`;
963
+ if (registeredRouteMap.has(key)) {
964
+ const existing = registeredRouteMap.get(key);
965
+ console.warn(`[webspresso] WARNING: Duplicate route detected: ${key} in "${r.file}" conflicts with existing route in "${existing.file}".`);
966
+ } else {
967
+ registeredRouteMap.set(key, r);
968
+ }
969
+ }
970
+
817
971
  const apiStatic = apiRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier === 0);
818
972
  const apiDynamic = apiRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier !== 0);
819
973
  const ssrStatic = ssrRoutes.filter((r) => routeRegistrationMeta(r.routePath).tier === 0);
@@ -841,6 +995,10 @@ function mountPages(app, options) {
841
995
  if (db != null) {
842
996
  req.db = db;
843
997
  }
998
+ if (serviceRegistry != null) {
999
+ req.service = (name, input, opts) =>
1000
+ serviceRegistry.call(name, input, { req, res, db }, opts);
1001
+ }
844
1002
 
845
1003
  // Reload handler in dev mode
846
1004
  if (isDev && require.cache[require.resolve(route.fullPath)]) {
@@ -918,17 +1076,26 @@ function mountPages(app, options) {
918
1076
  try {
919
1077
  // Detect locale
920
1078
  const locale = detectLocale(req);
1079
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
921
1080
 
922
- // Load translations
1081
+ // Load translations (primary + fallback)
923
1082
  const translations = loadI18n(absolutePagesDir, route.routeDir, locale);
924
- const t = createTranslator(translations);
1083
+ const fallbackTranslations = (locale !== defaultLocale)
1084
+ ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale)
1085
+ : EMPTY_OBJECT;
1086
+
1087
+ const t = createTranslator(translations, {
1088
+ locale,
1089
+ fallbackTranslations,
1090
+ fallbackLocale: defaultLocale,
1091
+ });
925
1092
 
926
1093
  // Load route config
927
1094
  const config = loadRouteConfig(route.configPath, isDev);
928
1095
  const routeHooks = config?.hooks || EMPTY_OBJECT;
929
1096
 
930
1097
  // Create context with plugin helpers merged
931
- const baseHelpers = createHelpers({ req, res, locale });
1098
+ const baseHelpers = createHelpers({ req, res, locale, t });
932
1099
  const pluginHelpers = pluginManager ? pluginManager.getHelpers() : EMPTY_OBJECT;
933
1100
  if (pluginManager) {
934
1101
  const contentApi = pluginManager.getPluginAPI('content');
@@ -949,6 +1116,12 @@ function mountPages(app, options) {
949
1116
  routeDir: route.routeDir,
950
1117
  locale,
951
1118
  t,
1119
+ service: (name, input, opts) => {
1120
+ if (serviceRegistry) {
1121
+ return serviceRegistry.call(name, input, ctx, opts);
1122
+ }
1123
+ throw new Error(`Services not configured. Cannot call service '${name}'.`);
1124
+ },
952
1125
  data: { ...njkTpl.dataPatch },
953
1126
  meta: {
954
1127
  title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
@@ -1002,7 +1175,9 @@ function mountPages(app, options) {
1002
1175
  // Run load function
1003
1176
  if (config?.load && typeof config.load === 'function') {
1004
1177
  const loadData = await config.load(req, ctx);
1005
- ctx.data = { ...ctx.data, ...loadData };
1178
+ if (loadData && typeof loadData === 'object' && !Array.isArray(loadData)) {
1179
+ ctx.data = { ...ctx.data, ...loadData };
1180
+ }
1006
1181
  }
1007
1182
 
1008
1183
  // Execute hooks: afterLoad
@@ -1012,7 +1187,9 @@ function mountPages(app, options) {
1012
1187
  // Run meta function
1013
1188
  if (config?.meta && typeof config.meta === 'function') {
1014
1189
  const metaData = await config.meta(req, ctx);
1015
- ctx.meta = { ...ctx.meta, ...metaData };
1190
+ if (metaData && typeof metaData === 'object' && !Array.isArray(metaData)) {
1191
+ ctx.meta = { ...ctx.meta, ...metaData };
1192
+ }
1016
1193
  }
1017
1194
 
1018
1195
  // Execute hooks: beforeRender
@@ -1041,6 +1218,18 @@ function mountPages(app, options) {
1041
1218
 
1042
1219
  // Render the template
1043
1220
  const templatePath = route.file.split(path.sep).join('/');
1221
+
1222
+ // SSR Streaming mode (chunked transfer)
1223
+ if (config?.stream || ctx.data?.__stream || ctx.defer) {
1224
+ const streamDefer = ctx.defer || ctx.data?.__defer || {};
1225
+ const status = (route.routePath === '/404' || route.file === '404.njk') ? 404 : 200;
1226
+ return await renderStream(res, templatePath, renderContext, {
1227
+ env: nunjucks,
1228
+ defer: streamDefer,
1229
+ status,
1230
+ });
1231
+ }
1232
+
1044
1233
  let html =
1045
1234
  njkTpl.useStringRender && njkTpl.templateBody != null
1046
1235
  ? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
@@ -1058,6 +1247,9 @@ function mountPages(app, options) {
1058
1247
  await executeHook(globalHooks, 'afterRender', ctx);
1059
1248
  await executeHook(routeHooks, 'afterRender', ctx);
1060
1249
 
1250
+ if (route.routePath === '/404' || route.file === '404.njk') {
1251
+ res.status(404);
1252
+ }
1061
1253
  res.send(ctx.html);
1062
1254
  } catch (err) {
1063
1255
  console.error(`SSR error ${route.routePath}:`, err);
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
@@ -236,7 +254,7 @@ function createHelpers(ctx) {
236
254
  * @returns {*}
237
255
  */
238
256
  q(name, def = null) {
239
- return req.query[name] !== undefined ? req.query[name] : def;
257
+ return req && req.query && req.query[name] !== undefined ? req.query[name] : def;
240
258
  },
241
259
 
242
260
  /**
@@ -246,7 +264,7 @@ function createHelpers(ctx) {
246
264
  * @returns {*}
247
265
  */
248
266
  param(name, def = null) {
249
- return req.params[name] !== undefined ? req.params[name] : def;
267
+ return req && req.params && req.params[name] !== undefined ? req.params[name] : def;
250
268
  },
251
269
 
252
270
  /**
@@ -256,7 +274,8 @@ function createHelpers(ctx) {
256
274
  * @returns {*}
257
275
  */
258
276
  hdr(name, def = null) {
259
- return req.get(name) || def;
277
+ if (!req) return def;
278
+ return (typeof req.get === 'function' ? req.get(name) : (req.headers && req.headers[name.toLowerCase()])) || def;
260
279
  },
261
280
 
262
281
  /**