webspresso 0.0.2 → 0.0.4

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.
@@ -0,0 +1,451 @@
1
+ /**
2
+ * Webspresso Plugin Manager
3
+ * Handles plugin registration, lifecycle, dependencies, and inter-plugin communication
4
+ */
5
+
6
+ /**
7
+ * Simple semver comparison utilities
8
+ * (Lightweight alternative to full semver package)
9
+ */
10
+ const semver = {
11
+ /**
12
+ * Parse version string to components
13
+ */
14
+ parse(version) {
15
+ if (!version) return null;
16
+ const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
17
+ if (!match) return null;
18
+ return {
19
+ major: parseInt(match[1], 10),
20
+ minor: parseInt(match[2], 10),
21
+ patch: parseInt(match[3], 10),
22
+ prerelease: match[4] || null
23
+ };
24
+ },
25
+
26
+ /**
27
+ * Compare two versions
28
+ * Returns: -1 if a < b, 0 if a == b, 1 if a > b
29
+ */
30
+ compare(a, b) {
31
+ const va = this.parse(a);
32
+ const vb = this.parse(b);
33
+ if (!va || !vb) return 0;
34
+
35
+ if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
36
+ if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
37
+ if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
38
+ return 0;
39
+ },
40
+
41
+ /**
42
+ * Check if version satisfies a range
43
+ * Supports: ^1.0.0, ~1.0.0, >=1.0.0, >1.0.0, <=1.0.0, <1.0.0, 1.0.0, *
44
+ */
45
+ satisfies(version, range) {
46
+ if (!range || range === '*') return true;
47
+
48
+ const v = this.parse(version);
49
+ if (!v) return false;
50
+
51
+ // Handle caret (^) - compatible with major version
52
+ if (range.startsWith('^')) {
53
+ const r = this.parse(range.slice(1));
54
+ if (!r) return false;
55
+ if (v.major !== r.major) return false;
56
+ if (v.major === 0) {
57
+ // For 0.x.x, minor must match
58
+ if (v.minor !== r.minor) return false;
59
+ return v.patch >= r.patch;
60
+ }
61
+ return this.compare(version, range.slice(1)) >= 0;
62
+ }
63
+
64
+ // Handle tilde (~) - compatible with minor version
65
+ if (range.startsWith('~')) {
66
+ const r = this.parse(range.slice(1));
67
+ if (!r) return false;
68
+ return v.major === r.major && v.minor === r.minor && v.patch >= r.patch;
69
+ }
70
+
71
+ // Handle >=
72
+ if (range.startsWith('>=')) {
73
+ return this.compare(version, range.slice(2)) >= 0;
74
+ }
75
+
76
+ // Handle >
77
+ if (range.startsWith('>') && !range.startsWith('>=')) {
78
+ return this.compare(version, range.slice(1)) > 0;
79
+ }
80
+
81
+ // Handle <=
82
+ if (range.startsWith('<=')) {
83
+ return this.compare(version, range.slice(2)) <= 0;
84
+ }
85
+
86
+ // Handle <
87
+ if (range.startsWith('<') && !range.startsWith('<=')) {
88
+ return this.compare(version, range.slice(1)) < 0;
89
+ }
90
+
91
+ // Exact match
92
+ return this.compare(version, range) === 0;
93
+ }
94
+ };
95
+
96
+ /**
97
+ * Simple glob/minimatch pattern matching
98
+ */
99
+ function matchPattern(path, pattern) {
100
+ if (pattern === '*' || pattern === '**') return true;
101
+
102
+ // Convert glob to regex
103
+ const regexPattern = pattern
104
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special regex chars
105
+ .replace(/\*\*/g, '{{GLOBSTAR}}') // Temp placeholder for **
106
+ .replace(/\*/g, '[^/]*') // * matches anything except /
107
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*') // ** matches everything
108
+ .replace(/\?/g, '.'); // ? matches single char
109
+
110
+ const regex = new RegExp(`^${regexPattern}$`);
111
+ return regex.test(path);
112
+ }
113
+
114
+ /**
115
+ * Plugin Manager Class
116
+ */
117
+ class PluginManager {
118
+ constructor() {
119
+ this.plugins = new Map(); // name -> plugin instance
120
+ this.pluginAPIs = new Map(); // name -> exposed API
121
+ this.registeredHelpers = new Map(); // name -> helper function
122
+ this.registeredFilters = new Map(); // name -> filter function
123
+ this.routes = []; // Collected route metadata
124
+ this.customRoutes = []; // Routes added by plugins
125
+ this.app = null;
126
+ this.nunjucksEnv = null;
127
+ }
128
+
129
+ /**
130
+ * Register plugins with the manager
131
+ * @param {Array} plugins - Array of plugin definitions or factory functions
132
+ * @param {Object} context - Context object { app, nunjucksEnv, options }
133
+ */
134
+ async register(plugins, context) {
135
+ if (!plugins || !Array.isArray(plugins)) return;
136
+
137
+ this.app = context.app;
138
+ this.nunjucksEnv = context.nunjucksEnv;
139
+
140
+ // Normalize plugins (handle factory functions)
141
+ const normalizedPlugins = plugins.map(p => {
142
+ if (typeof p === 'function') {
143
+ // Already a factory that was called
144
+ return p;
145
+ }
146
+ return p;
147
+ });
148
+
149
+ // Validate and sort by dependencies
150
+ const sorted = this._resolveDependencyOrder(normalizedPlugins);
151
+
152
+ // Register each plugin in order
153
+ for (const plugin of sorted) {
154
+ await this._registerPlugin(plugin, context);
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Resolve dependency order using topological sort
160
+ */
161
+ _resolveDependencyOrder(plugins) {
162
+ const graph = new Map();
163
+ const pluginMap = new Map();
164
+
165
+ // Build graph
166
+ for (const plugin of plugins) {
167
+ if (!plugin.name) {
168
+ throw new Error('Plugin must have a name');
169
+ }
170
+ if (pluginMap.has(plugin.name)) {
171
+ throw new Error(`Duplicate plugin name: ${plugin.name}`);
172
+ }
173
+ pluginMap.set(plugin.name, plugin);
174
+ graph.set(plugin.name, new Set(Object.keys(plugin.dependencies || {})));
175
+ }
176
+
177
+ // Topological sort (Kahn's algorithm)
178
+ const sorted = [];
179
+ const noIncoming = [];
180
+
181
+ // Find nodes with no dependencies
182
+ for (const [name, deps] of graph) {
183
+ // Filter to only include dependencies that are in our plugin list
184
+ const relevantDeps = new Set([...deps].filter(d => pluginMap.has(d)));
185
+ graph.set(name, relevantDeps);
186
+ if (relevantDeps.size === 0) {
187
+ noIncoming.push(name);
188
+ }
189
+ }
190
+
191
+ while (noIncoming.length > 0) {
192
+ const name = noIncoming.shift();
193
+ sorted.push(pluginMap.get(name));
194
+
195
+ // Remove this node from all dependencies
196
+ for (const [n, deps] of graph) {
197
+ if (deps.has(name)) {
198
+ deps.delete(name);
199
+ if (deps.size === 0 && !sorted.find(p => p.name === n)) {
200
+ noIncoming.push(n);
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // Check for cycles
207
+ if (sorted.length !== plugins.length) {
208
+ const remaining = plugins.filter(p => !sorted.includes(p)).map(p => p.name);
209
+ throw new Error(`Circular dependency detected in plugins: ${remaining.join(', ')}`);
210
+ }
211
+
212
+ return sorted;
213
+ }
214
+
215
+ /**
216
+ * Register a single plugin
217
+ */
218
+ async _registerPlugin(plugin, context) {
219
+ // Validate dependencies
220
+ this._validateDependencies(plugin);
221
+
222
+ // Create plugin context
223
+ const ctx = this._createPluginContext(plugin, context);
224
+
225
+ // Store plugin
226
+ this.plugins.set(plugin.name, plugin);
227
+
228
+ // Store API if provided
229
+ if (plugin.api) {
230
+ // Bind API methods to plugin instance
231
+ const boundAPI = {};
232
+ for (const [key, value] of Object.entries(plugin.api)) {
233
+ boundAPI[key] = typeof value === 'function' ? value.bind(plugin) : value;
234
+ }
235
+ this.pluginAPIs.set(plugin.name, boundAPI);
236
+ }
237
+
238
+ // Call register hook
239
+ if (typeof plugin.register === 'function') {
240
+ await plugin.register(ctx);
241
+ }
242
+
243
+ // Apply registered helpers to nunjucks
244
+ this._applyHelpersAndFilters();
245
+ }
246
+
247
+ /**
248
+ * Validate plugin dependencies
249
+ */
250
+ _validateDependencies(plugin) {
251
+ if (!plugin.dependencies) return;
252
+
253
+ for (const [depName, versionRange] of Object.entries(plugin.dependencies)) {
254
+ const dep = this.plugins.get(depName);
255
+
256
+ if (!dep) {
257
+ throw new Error(
258
+ `Plugin "${plugin.name}" requires "${depName}" but it's not loaded`
259
+ );
260
+ }
261
+
262
+ if (dep.version && !semver.satisfies(dep.version, versionRange)) {
263
+ throw new Error(
264
+ `Plugin "${plugin.name}" requires "${depName}@${versionRange}" ` +
265
+ `but found v${dep.version}`
266
+ );
267
+ }
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Create context object for plugin
273
+ */
274
+ _createPluginContext(plugin, context) {
275
+ const self = this;
276
+
277
+ return {
278
+ app: context.app,
279
+ options: plugin._options || {},
280
+ nunjucksEnv: context.nunjucksEnv,
281
+
282
+ /**
283
+ * Get another plugin's API
284
+ */
285
+ usePlugin(name) {
286
+ return self.pluginAPIs.get(name) || null;
287
+ },
288
+
289
+ /**
290
+ * Add a template helper (available as fsy.helperName())
291
+ */
292
+ addHelper(name, fn) {
293
+ self.registeredHelpers.set(name, fn);
294
+ },
295
+
296
+ /**
297
+ * Add a Nunjucks filter
298
+ */
299
+ addFilter(name, fn) {
300
+ self.registeredFilters.set(name, fn);
301
+ },
302
+
303
+ /**
304
+ * Add a custom route
305
+ */
306
+ addRoute(method, path, ...handlers) {
307
+ self.customRoutes.push({ method: method.toLowerCase(), path, handlers });
308
+ },
309
+
310
+ /**
311
+ * Get all registered routes (available after onRoutesReady)
312
+ */
313
+ get routes() {
314
+ return self.routes;
315
+ }
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Apply registered helpers and filters to Nunjucks
321
+ */
322
+ _applyHelpersAndFilters() {
323
+ if (!this.nunjucksEnv) return;
324
+
325
+ // Add filters
326
+ for (const [name, fn] of this.registeredFilters) {
327
+ this.nunjucksEnv.addFilter(name, fn);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Get all registered helpers (to be merged with fsy)
333
+ */
334
+ getHelpers() {
335
+ const helpers = {};
336
+ for (const [name, fn] of this.registeredHelpers) {
337
+ helpers[name] = fn;
338
+ }
339
+ return helpers;
340
+ }
341
+
342
+ /**
343
+ * Set route metadata (called by file-router after mounting)
344
+ */
345
+ setRoutes(routes) {
346
+ this.routes = routes;
347
+ }
348
+
349
+ /**
350
+ * Mount custom routes added by plugins
351
+ */
352
+ mountCustomRoutes() {
353
+ for (const { method, path, handlers } of this.customRoutes) {
354
+ if (this.app && this.app[method]) {
355
+ this.app[method](path, ...handlers);
356
+ }
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Call onRoutesReady hook on all plugins
362
+ */
363
+ async onRoutesReady(context) {
364
+ for (const [name, plugin] of this.plugins) {
365
+ if (typeof plugin.onRoutesReady === 'function') {
366
+ const ctx = this._createPluginContext(plugin, context);
367
+ await plugin.onRoutesReady(ctx);
368
+ }
369
+ }
370
+
371
+ // Mount any routes added during onRoutesReady
372
+ this.mountCustomRoutes();
373
+ }
374
+
375
+ /**
376
+ * Call onReady hook on all plugins
377
+ */
378
+ async onReady(context) {
379
+ for (const [name, plugin] of this.plugins) {
380
+ if (typeof plugin.onReady === 'function') {
381
+ const ctx = this._createPluginContext(plugin, context);
382
+ await plugin.onReady(ctx);
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Get a plugin by name
389
+ */
390
+ getPlugin(name) {
391
+ return this.plugins.get(name);
392
+ }
393
+
394
+ /**
395
+ * Get a plugin's API by name
396
+ */
397
+ getPluginAPI(name) {
398
+ return this.pluginAPIs.get(name);
399
+ }
400
+
401
+ /**
402
+ * Check if a plugin is registered
403
+ */
404
+ hasPlugin(name) {
405
+ return this.plugins.has(name);
406
+ }
407
+
408
+ /**
409
+ * Get all registered plugin names
410
+ */
411
+ getPluginNames() {
412
+ return Array.from(this.plugins.keys());
413
+ }
414
+ }
415
+
416
+ // Singleton instance for global access
417
+ let globalPluginManager = null;
418
+
419
+ /**
420
+ * Get or create the global plugin manager
421
+ */
422
+ function getPluginManager() {
423
+ if (!globalPluginManager) {
424
+ globalPluginManager = new PluginManager();
425
+ }
426
+ return globalPluginManager;
427
+ }
428
+
429
+ /**
430
+ * Create a new plugin manager (for testing)
431
+ */
432
+ function createPluginManager() {
433
+ return new PluginManager();
434
+ }
435
+
436
+ /**
437
+ * Reset the global plugin manager (for testing)
438
+ */
439
+ function resetPluginManager() {
440
+ globalPluginManager = null;
441
+ }
442
+
443
+ module.exports = {
444
+ PluginManager,
445
+ getPluginManager,
446
+ createPluginManager,
447
+ resetPluginManager,
448
+ semver,
449
+ matchPattern
450
+ };
451
+