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.
- package/README.md +98 -2509
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +199 -0
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/middleware.js +9 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +63 -0
- package/core/compression/compression-stream.js +59 -0
- package/core/compression/index.js +25 -0
- package/core/compression/middleware.js +310 -0
- package/core/compression/negotiate.js +165 -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/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/complexity.js +120 -0
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/events.js +64 -14
- package/core/orm/index.js +83 -12
- package/core/orm/model.js +13 -2
- package/core/orm/query-builder.js +30 -4
- package/core/orm/repository.js +57 -25
- package/core/orm/transaction.js +39 -5
- package/core/orm/utils.js +6 -3
- package/core/queue/adapters/database.js +317 -0
- package/core/queue/adapters/memory.js +176 -0
- package/core/queue/adapters/redis.js +357 -0
- package/core/queue/index.js +21 -0
- package/core/queue/job.js +103 -0
- package/core/queue/manager.js +413 -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/core/validation/index.js +70 -0
- package/index.d.ts +924 -53
- package/index.js +110 -1
- package/package.json +30 -2
- package/plugins/admin-panel/api.js +37 -4
- package/plugins/admin-panel/app.js +57 -17
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +10 -2
- package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +51 -27
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +37 -26
- 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 +155 -23
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/custom-pages.js +144 -45
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- 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/data-exchange/export-xlsx.js +28 -2
- package/plugins/data-exchange/import.js +55 -16
- package/plugins/data-exchange/index.js +19 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/email/index.js +14 -1
- package/plugins/index.js +33 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +139 -0
- package/plugins/mcp/transports/stdio.js +108 -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 +366 -130
- package/src/helpers.js +20 -2
- package/src/njk-frontmatter.js +24 -14
- package/src/plugin-manager.js +124 -4
- package/src/server.js +332 -54
- 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 +141 -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 +290 -0
- package/src/services/validator.js +230 -0
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +6 -4
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({});
|
|
@@ -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)
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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 (
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
-
|
|
903
|
-
}
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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(([
|
|
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
|