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.
- package/README.md +450 -53
- package/bin/commands/service.js +193 -0
- package/bin/webspresso.js +2 -0
- package/core/auth/manager.js +36 -26
- package/core/auth/middleware.js +21 -2
- package/core/compression/compressible.js +51 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +301 -0
- package/core/compression/negotiate.js +153 -0
- package/core/errors/base.js +31 -0
- package/core/errors/domain.js +193 -0
- package/core/errors/http.js +133 -0
- package/core/errors/index.js +77 -0
- package/core/errors/middleware.js +162 -0
- package/core/errors/normalize.js +171 -0
- package/core/orm/complexity.js +120 -0
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +45 -10
- package/core/orm/model.js +10 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/scopes.js +2 -1
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +284 -0
- package/core/queue/adapters/memory.js +155 -0
- package/core/queue/adapters/redis.js +336 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +387 -0
- package/core/realtime/adapters/redis.js +431 -0
- package/core/realtime/adapters/socket-io.js +203 -0
- package/core/realtime/adapters/sse.js +210 -0
- package/core/realtime/adapters/websocket.js +337 -0
- package/core/realtime/auth-manager.js +140 -0
- package/core/realtime/core.js +359 -0
- package/core/realtime/emitter.js +98 -0
- package/core/realtime/errors.js +71 -0
- package/core/realtime/index.js +79 -0
- package/core/realtime/reconnect-manager.js +118 -0
- package/core/realtime/registry.js +243 -0
- package/core/shutdown/http-adapter.js +134 -0
- package/core/shutdown/index.js +12 -0
- package/core/shutdown/shutdown-manager.js +312 -0
- package/core/ssr/index.js +13 -0
- package/core/ssr/stream.js +174 -0
- package/index.d.ts +829 -52
- package/index.js +103 -0
- package/package.json +22 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +67 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +25 -18
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
- package/plugins/admin-panel/client/parts/08-record-list.js +27 -34
- package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
- package/plugins/admin-panel/core/admin-module.js +2 -2
- package/plugins/admin-panel/core/api-extensions.js +149 -0
- package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
- package/plugins/admin-panel/index.js +131 -18
- package/plugins/admin-panel/modules/admin-users.js +770 -0
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/menu.js +85 -0
- package/plugins/admin-panel/modules/user-management.js +5 -2
- package/plugins/admin-panel/vendor/mithril.min.js +1 -0
- package/plugins/admin-panel/vendor/quill.min.js +7 -0
- package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
- package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
- package/plugins/basic-auth/index.js +293 -0
- package/plugins/content/client/inline-edit.js +41 -17
- package/plugins/cors.js +12 -8
- package/plugins/csrf/index.js +8 -8
- package/plugins/data-exchange/export-xlsx.js +5 -1
- package/plugins/data-exchange/import.js +16 -2
- package/plugins/data-exchange/index.js +14 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +29 -0
- package/plugins/queue/index.js +108 -0
- package/plugins/realtime/index.js +150 -0
- package/plugins/recaptcha/index.js +11 -0
- package/plugins/redirect/index.js +9 -1
- package/plugins/rest-resources/index.js +280 -11
- package/plugins/upload/index.js +16 -0
- package/src/app-context.js +46 -6
- package/src/file-router.js +220 -28
- package/src/helpers.js +24 -5
- package/src/plugin-manager.js +134 -7
- package/src/server.js +470 -44
- package/src/services/builtins/auth.js +273 -0
- package/src/services/builtins/exchange.js +229 -0
- package/src/services/builtins/mail.js +134 -0
- package/src/services/builtins/media.js +196 -0
- package/src/services/builtins/system.js +221 -0
- package/src/services/discovery.js +140 -0
- package/src/services/executor.js +292 -0
- package/src/services/index.js +70 -0
- package/src/services/memoize.js +256 -0
- package/src/services/registry.js +288 -0
- package/src/services/validator.js +281 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +2 -2
package/src/app-context.js
CHANGED
|
@@ -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
|
};
|
package/src/file-router.js
CHANGED
|
@@ -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 -
|
|
438
|
-
* @
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
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
|
|
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 (
|
|
464
|
-
for (const [paramKey, paramValue] of Object.entries(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(([
|
|
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
|
-
|
|
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
|
/**
|