webspresso 0.0.90 → 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 (94) hide show
  1. package/README.md +358 -8
  2. package/bin/commands/service.js +193 -0
  3. package/bin/webspresso.js +2 -0
  4. package/core/auth/middleware.js +9 -0
  5. package/core/compression/compressible.js +51 -0
  6. package/core/compression/compression-stream.js +59 -0
  7. package/core/compression/index.js +25 -0
  8. package/core/compression/middleware.js +301 -0
  9. package/core/compression/negotiate.js +153 -0
  10. package/core/errors/base.js +31 -0
  11. package/core/errors/domain.js +193 -0
  12. package/core/errors/http.js +133 -0
  13. package/core/errors/index.js +77 -0
  14. package/core/errors/middleware.js +162 -0
  15. package/core/errors/normalize.js +171 -0
  16. package/core/orm/complexity.js +120 -0
  17. package/core/orm/events.js +64 -14
  18. package/core/orm/index.js +45 -10
  19. package/core/orm/model.js +2 -0
  20. package/core/orm/query-builder.js +30 -4
  21. package/core/orm/repository.js +57 -25
  22. package/core/orm/transaction.js +39 -5
  23. package/core/orm/utils.js +6 -3
  24. package/core/queue/adapters/database.js +284 -0
  25. package/core/queue/adapters/memory.js +155 -0
  26. package/core/queue/adapters/redis.js +336 -0
  27. package/core/queue/index.js +21 -0
  28. package/core/queue/job.js +103 -0
  29. package/core/queue/manager.js +387 -0
  30. package/core/realtime/adapters/redis.js +431 -0
  31. package/core/realtime/adapters/socket-io.js +203 -0
  32. package/core/realtime/adapters/sse.js +210 -0
  33. package/core/realtime/adapters/websocket.js +337 -0
  34. package/core/realtime/auth-manager.js +140 -0
  35. package/core/realtime/core.js +359 -0
  36. package/core/realtime/emitter.js +98 -0
  37. package/core/realtime/errors.js +71 -0
  38. package/core/realtime/index.js +79 -0
  39. package/core/realtime/reconnect-manager.js +118 -0
  40. package/core/realtime/registry.js +243 -0
  41. package/core/shutdown/http-adapter.js +134 -0
  42. package/core/shutdown/index.js +12 -0
  43. package/core/shutdown/shutdown-manager.js +312 -0
  44. package/core/ssr/index.js +13 -0
  45. package/core/ssr/stream.js +174 -0
  46. package/index.d.ts +829 -52
  47. package/index.js +103 -0
  48. package/package.json +22 -2
  49. package/plugins/admin-panel/api.js +37 -4
  50. package/plugins/admin-panel/app.js +57 -17
  51. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +4 -1
  52. package/plugins/admin-panel/client/parts/04-field-renderers.js +102 -58
  53. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +37 -23
  54. package/plugins/admin-panel/client/parts/08-record-list.js +17 -24
  55. package/plugins/admin-panel/client/parts/09-record-form.js +168 -19
  56. package/plugins/admin-panel/core/admin-module.js +2 -2
  57. package/plugins/admin-panel/core/api-extensions.js +149 -0
  58. package/plugins/admin-panel/field-renderers/rich-text.js +3 -2
  59. package/plugins/admin-panel/index.js +90 -17
  60. package/plugins/admin-panel/modules/custom-pages.js +144 -45
  61. package/plugins/admin-panel/modules/menu.js +85 -0
  62. package/plugins/admin-panel/modules/user-management.js +5 -2
  63. package/plugins/admin-panel/vendor/mithril.min.js +1 -0
  64. package/plugins/admin-panel/vendor/quill.min.js +7 -0
  65. package/plugins/admin-panel/vendor/quill.snow.min.css +945 -0
  66. package/plugins/admin-panel/vendor/tailwind.min.js +65 -0
  67. package/plugins/basic-auth/index.js +293 -0
  68. package/plugins/data-exchange/export-xlsx.js +5 -1
  69. package/plugins/data-exchange/import.js +16 -2
  70. package/plugins/data-exchange/index.js +14 -0
  71. package/plugins/email/index.js +14 -1
  72. package/plugins/index.js +29 -0
  73. package/plugins/queue/index.js +108 -0
  74. package/plugins/realtime/index.js +150 -0
  75. package/plugins/recaptcha/index.js +11 -0
  76. package/plugins/redirect/index.js +9 -1
  77. package/plugins/rest-resources/index.js +280 -11
  78. package/plugins/upload/index.js +16 -0
  79. package/src/app-context.js +46 -6
  80. package/src/file-router.js +196 -21
  81. package/src/helpers.js +20 -2
  82. package/src/plugin-manager.js +124 -4
  83. package/src/server.js +304 -44
  84. package/src/services/builtins/auth.js +273 -0
  85. package/src/services/builtins/exchange.js +229 -0
  86. package/src/services/builtins/mail.js +134 -0
  87. package/src/services/builtins/media.js +196 -0
  88. package/src/services/builtins/system.js +221 -0
  89. package/src/services/discovery.js +140 -0
  90. package/src/services/executor.js +292 -0
  91. package/src/services/index.js +70 -0
  92. package/src/services/memoize.js +256 -0
  93. package/src/services/registry.js +288 -0
  94. package/src/services/validator.js +281 -0
@@ -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,33 +433,103 @@ function loadI18n(pagesDir, routeDir, locale) {
432
433
  return data;
433
434
  }
434
435
 
435
- function createTranslator(translations = {}) {
436
- return function t(key, params = EMPTY_OBJECT, defaultVal = null) {
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
+
470
+ /**
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`
478
+ */
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) {
437
485
  const defaultValue = typeof params === 'string' ? params : defaultVal;
438
486
  const interpolationParams = typeof params === 'object' && params !== null ? params : EMPTY_OBJECT;
439
487
 
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];
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;
449
509
  } else {
450
- value = undefined;
451
- 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
+ }
452
519
  }
453
520
  }
454
521
  }
455
-
522
+
456
523
  if (value === undefined) {
457
524
  return defaultValue !== null && defaultValue !== undefined ? defaultValue : key;
458
525
  }
459
-
526
+
527
+ if (typeof value !== 'string') {
528
+ return value;
529
+ }
530
+
460
531
  // Replace params like {{name}} in the translation
461
- if (typeof value === 'string' && interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
532
+ if (interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
462
533
  for (const [paramKey, paramValue] of Object.entries(interpolationParams)) {
463
534
  let regex = paramRegexCache.get(paramKey);
464
535
  if (regex === undefined) {
@@ -468,12 +539,84 @@ function createTranslator(translations = {}) {
468
539
  paramRegexCache.set(paramKey, regex);
469
540
  }
470
541
  }
471
- value = value.replace(regex, paramValue);
542
+ value = value.replace(regex, () => String(paramValue));
472
543
  }
473
544
  }
474
-
545
+
475
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
+ }
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;
476
616
  };
617
+ t.exists = t.has;
618
+
619
+ return t;
477
620
  }
478
621
 
479
622
  /**
@@ -677,6 +820,7 @@ function mountPages(app, options) {
677
820
  db = null,
678
821
  clientRuntime: clientRuntimeOpt = null,
679
822
  pageAssets: pageAssetsOpt = null,
823
+ serviceRegistry = null,
680
824
  } = options;
681
825
  const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
682
826
  const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
@@ -851,6 +995,10 @@ function mountPages(app, options) {
851
995
  if (db != null) {
852
996
  req.db = db;
853
997
  }
998
+ if (serviceRegistry != null) {
999
+ req.service = (name, input, opts) =>
1000
+ serviceRegistry.call(name, input, { req, res, db }, opts);
1001
+ }
854
1002
 
855
1003
  // Reload handler in dev mode
856
1004
  if (isDev && require.cache[require.resolve(route.fullPath)]) {
@@ -928,17 +1076,26 @@ function mountPages(app, options) {
928
1076
  try {
929
1077
  // Detect locale
930
1078
  const locale = detectLocale(req);
1079
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
931
1080
 
932
- // Load translations
1081
+ // Load translations (primary + fallback)
933
1082
  const translations = loadI18n(absolutePagesDir, route.routeDir, locale);
934
- 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
+ });
935
1092
 
936
1093
  // Load route config
937
1094
  const config = loadRouteConfig(route.configPath, isDev);
938
1095
  const routeHooks = config?.hooks || EMPTY_OBJECT;
939
1096
 
940
1097
  // Create context with plugin helpers merged
941
- const baseHelpers = createHelpers({ req, res, locale });
1098
+ const baseHelpers = createHelpers({ req, res, locale, t });
942
1099
  const pluginHelpers = pluginManager ? pluginManager.getHelpers() : EMPTY_OBJECT;
943
1100
  if (pluginManager) {
944
1101
  const contentApi = pluginManager.getPluginAPI('content');
@@ -959,6 +1116,12 @@ function mountPages(app, options) {
959
1116
  routeDir: route.routeDir,
960
1117
  locale,
961
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
+ },
962
1125
  data: { ...njkTpl.dataPatch },
963
1126
  meta: {
964
1127
  title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
@@ -1055,6 +1218,18 @@ function mountPages(app, options) {
1055
1218
 
1056
1219
  // Render the template
1057
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
+
1058
1233
  let html =
1059
1234
  njkTpl.useStringRender && njkTpl.templateBody != null
1060
1235
  ? 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
@@ -123,20 +123,37 @@ class PluginManager {
123
123
  this.routes = []; // Collected route metadata
124
124
  this.customRoutes = []; // Routes added by plugins
125
125
  this.cspDirectives = new Map(); // directive -> Set of sources (from plugins)
126
+ this.disposers = []; // Array<{ name: string, fn: Function }>
126
127
  this.app = null;
127
128
  this.nunjucksEnv = null;
129
+ this.shutdownManager = null;
130
+ }
131
+
132
+ /**
133
+ * Register a plugin disposer
134
+ * @param {string} name
135
+ * @param {Function} fn
136
+ */
137
+ registerDisposer(name, fn) {
138
+ if (typeof fn === 'function') {
139
+ this.disposers.push({ name: name || 'plugin', fn });
140
+ if (this.shutdownManager && typeof this.shutdownManager.registerDisposer === 'function') {
141
+ this.shutdownManager.registerDisposer(name, fn);
142
+ }
143
+ }
128
144
  }
129
145
 
130
146
  /**
131
147
  * Register plugins with the manager (async version)
132
148
  * @param {Array} plugins - Array of plugin definitions or factory functions
133
- * @param {Object} context - Context object `{ app, nunjucksEnv, options, middlewares? }`
149
+ * @param {Object} context - Context object `{ app, nunjucksEnv, options, middlewares?, shutdownManager? }`
134
150
  */
135
151
  async register(plugins, context) {
136
152
  if (!plugins || !Array.isArray(plugins)) return;
137
153
 
138
154
  this.app = context.app;
139
155
  this.nunjucksEnv = context.nunjucksEnv;
156
+ this.shutdownManager = context.shutdownManager || null;
140
157
 
141
158
  // Normalize plugins (handle factory functions)
142
159
  const normalizedPlugins = plugins.map(p => {
@@ -159,13 +176,14 @@ class PluginManager {
159
176
  /**
160
177
  * Register plugins with the manager (sync version)
161
178
  * @param {Array} plugins - Array of plugin definitions or factory functions
162
- * @param {Object} context - Context object `{ app, nunjucksEnv, options, middlewares? }`
179
+ * @param {Object} context - Context object `{ app, nunjucksEnv, options, middlewares?, shutdownManager? }`
163
180
  */
164
181
  registerSync(plugins, context) {
165
182
  if (!plugins || !Array.isArray(plugins)) return;
166
183
 
167
184
  this.app = context.app;
168
185
  this.nunjucksEnv = context.nunjucksEnv;
186
+ this.shutdownManager = context.shutdownManager || null;
169
187
 
170
188
  // Normalize plugins (handle factory functions)
171
189
  const normalizedPlugins = plugins.map(p => {
@@ -276,10 +294,32 @@ class PluginManager {
276
294
  this._collectCspDirectives(plugin.csp);
277
295
  }
278
296
 
297
+ let hasDisposer = false;
298
+
299
+ // Call setup hook if provided
300
+ if (typeof plugin.setup === 'function') {
301
+ try {
302
+ const result = await plugin.setup(ctx.app || ctx);
303
+ if (typeof result === 'function') {
304
+ this.registerDisposer(plugin.name, result);
305
+ hasDisposer = true;
306
+ }
307
+ } catch (err) {
308
+ console.warn(`[plugin-manager] Plugin "${plugin.name}" setup() failed:`, err.message);
309
+ this.plugins.delete(plugin.name);
310
+ if (plugin.api) this.pluginAPIs.delete(plugin.name);
311
+ return;
312
+ }
313
+ }
314
+
279
315
  // Call register hook
280
316
  if (typeof plugin.register === 'function') {
281
317
  try {
282
- await plugin.register(ctx);
318
+ const result = await plugin.register(ctx);
319
+ if (typeof result === 'function') {
320
+ this.registerDisposer(plugin.name, result);
321
+ hasDisposer = true;
322
+ }
283
323
  } catch (err) {
284
324
  console.warn(`[plugin-manager] Plugin "${plugin.name}" register() failed:`, err.message);
285
325
  this.plugins.delete(plugin.name);
@@ -288,6 +328,15 @@ class PluginManager {
288
328
  }
289
329
  }
290
330
 
331
+ // Support explicit shutdown or dispose methods if not already registered
332
+ if (!hasDisposer) {
333
+ if (typeof plugin.shutdown === 'function') {
334
+ this.registerDisposer(plugin.name, () => plugin.shutdown(ctx.app || ctx));
335
+ } else if (typeof plugin.dispose === 'function') {
336
+ this.registerDisposer(plugin.name, () => plugin.dispose(ctx));
337
+ }
338
+ }
339
+
291
340
  // Apply registered helpers to nunjucks
292
341
  this._applyHelpersAndFilters();
293
342
  }
@@ -320,10 +369,32 @@ class PluginManager {
320
369
  this._collectCspDirectives(plugin.csp);
321
370
  }
322
371
 
372
+ let hasDisposer = false;
373
+
374
+ // Call setup hook if provided
375
+ if (typeof plugin.setup === 'function') {
376
+ try {
377
+ const result = plugin.setup(ctx.app || ctx);
378
+ if (typeof result === 'function') {
379
+ this.registerDisposer(plugin.name, result);
380
+ hasDisposer = true;
381
+ }
382
+ } catch (err) {
383
+ console.warn(`[plugin-manager] Plugin "${plugin.name}" setup() failed:`, err.message);
384
+ this.plugins.delete(plugin.name);
385
+ if (plugin.api) this.pluginAPIs.delete(plugin.name);
386
+ return;
387
+ }
388
+ }
389
+
323
390
  // Call register hook (sync - if plugin has async register, it won't wait)
324
391
  if (typeof plugin.register === 'function') {
325
392
  try {
326
- plugin.register(ctx);
393
+ const result = plugin.register(ctx);
394
+ if (typeof result === 'function') {
395
+ this.registerDisposer(plugin.name, result);
396
+ hasDisposer = true;
397
+ }
327
398
  } catch (err) {
328
399
  console.warn(`[plugin-manager] Plugin "${plugin.name}" register() failed:`, err.message);
329
400
  this.plugins.delete(plugin.name);
@@ -332,6 +403,15 @@ class PluginManager {
332
403
  }
333
404
  }
334
405
 
406
+ // Support explicit shutdown or dispose methods if not already registered
407
+ if (!hasDisposer) {
408
+ if (typeof plugin.shutdown === 'function') {
409
+ this.registerDisposer(plugin.name, () => plugin.shutdown(ctx.app || ctx));
410
+ } else if (typeof plugin.dispose === 'function') {
411
+ this.registerDisposer(plugin.name, () => plugin.dispose(ctx));
412
+ }
413
+ }
414
+
335
415
  // Apply registered helpers to nunjucks
336
416
  this._applyHelpersAndFilters();
337
417
  }
@@ -421,6 +501,22 @@ class PluginManager {
421
501
  // Script Injection API
422
502
  // ============================================
423
503
 
504
+ /**
505
+ * Register a cleanup / disposer hook for this plugin
506
+ * @param {Function} fn
507
+ */
508
+ onDispose(fn) {
509
+ self.registerDisposer(plugin.name, fn);
510
+ },
511
+
512
+ /**
513
+ * Alias for onDispose
514
+ * @param {Function} fn
515
+ */
516
+ onShutdown(fn) {
517
+ self.registerDisposer(plugin.name, fn);
518
+ },
519
+
424
520
  /**
425
521
  * Inject content into head section
426
522
  * @param {string} content - HTML/script content
@@ -583,6 +679,30 @@ class PluginManager {
583
679
  return this.plugins.has(name);
584
680
  }
585
681
 
682
+ /**
683
+ * Get all registered disposers
684
+ * @returns {Array<{ name: string, fn: Function }>}
685
+ */
686
+ getDisposers() {
687
+ return this.disposers.slice();
688
+ }
689
+
690
+ /**
691
+ * Execute all registered plugin disposers in reverse registration order
692
+ * @returns {Promise<void>}
693
+ */
694
+ async dispose() {
695
+ const reverseDisposers = this.disposers.slice().reverse();
696
+ for (const { name, fn } of reverseDisposers) {
697
+ try {
698
+ await fn();
699
+ } catch (err) {
700
+ console.warn(`[plugin-manager] Plugin "${name}" disposer failed:`, err.message);
701
+ }
702
+ }
703
+ this.disposers = [];
704
+ }
705
+
586
706
  /**
587
707
  * Get all registered plugin names
588
708
  */