webspresso 0.0.8 → 0.0.10

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,398 @@
1
+ /**
2
+ * Schema Explorer Plugin
3
+ * Exposes ORM model metadata via API endpoint
4
+ * Useful for documentation, admin tools, and frontend code generation
5
+ */
6
+
7
+ const { getAllModels, getModel } = require('../core/orm/model');
8
+ const { getColumnMeta } = require('../core/orm/schema-helpers');
9
+
10
+ /**
11
+ * Create Schema Explorer plugin
12
+ * @param {Object} options - Plugin options
13
+ * @param {string} [options.path='/_schema'] - API endpoint path
14
+ * @param {boolean} [options.enabled] - Force enable/disable (default: auto based on NODE_ENV)
15
+ * @param {string[]} [options.exclude=[]] - Model names to exclude
16
+ * @param {boolean} [options.includeColumns=true] - Include column metadata
17
+ * @param {boolean} [options.includeRelations=true] - Include relation metadata
18
+ * @param {boolean} [options.includeScopes=true] - Include scope configuration
19
+ * @param {Function} [options.authorize] - Custom authorization function (req) => boolean
20
+ * @returns {Object} Plugin definition
21
+ */
22
+ function schemaExplorerPlugin(options = {}) {
23
+ const {
24
+ path = '/_schema',
25
+ enabled,
26
+ exclude = [],
27
+ includeColumns = true,
28
+ includeRelations = true,
29
+ includeScopes = true,
30
+ authorize,
31
+ } = options;
32
+
33
+ return {
34
+ name: 'schema-explorer',
35
+ version: '1.0.0',
36
+
37
+ // Expose API for other plugins
38
+ api: {
39
+ /**
40
+ * Get all models metadata
41
+ * @returns {Object[]}
42
+ */
43
+ getModels() {
44
+ return serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
45
+ },
46
+
47
+ /**
48
+ * Get single model metadata
49
+ * @param {string} name - Model name
50
+ * @returns {Object|null}
51
+ */
52
+ getModel(name) {
53
+ const model = getModel(name);
54
+ if (!model || exclude.includes(name)) return null;
55
+ return serializeModel(model, { includeColumns, includeRelations, includeScopes });
56
+ },
57
+
58
+ /**
59
+ * Get model names
60
+ * @returns {string[]}
61
+ */
62
+ getModelNames() {
63
+ const models = getAllModels();
64
+ return [...models.keys()].filter(name => !exclude.includes(name));
65
+ },
66
+ },
67
+
68
+ onRoutesReady(ctx) {
69
+ const isDev = process.env.NODE_ENV !== 'production';
70
+ const isEnabled = enabled !== undefined ? enabled : isDev;
71
+
72
+ if (!isEnabled) {
73
+ return;
74
+ }
75
+
76
+ // List all models
77
+ ctx.addRoute('get', path, (req, res) => {
78
+ // Check authorization
79
+ if (authorize && !authorize(req)) {
80
+ return res.status(403).json({ error: 'Forbidden' });
81
+ }
82
+
83
+ const models = serializeAllModels({ exclude, includeColumns, includeRelations, includeScopes });
84
+
85
+ res.json({
86
+ meta: {
87
+ version: '1.0.0',
88
+ generatedAt: new Date().toISOString(),
89
+ modelCount: models.length,
90
+ },
91
+ models,
92
+ });
93
+ });
94
+
95
+ // OpenAPI/JSON Schema export (must be registered before :modelName route)
96
+ ctx.addRoute('get', `${path}/openapi`, (req, res) => {
97
+ // Check authorization
98
+ if (authorize && !authorize(req)) {
99
+ return res.status(403).json({ error: 'Forbidden' });
100
+ }
101
+
102
+ const openApiSchemas = generateOpenApiSchemas({ exclude });
103
+
104
+ res.json({
105
+ openapi: '3.0.0',
106
+ info: {
107
+ title: 'Database Schema',
108
+ version: '1.0.0',
109
+ },
110
+ components: {
111
+ schemas: openApiSchemas,
112
+ },
113
+ });
114
+ });
115
+
116
+ // Get single model by name (must be after /openapi to avoid matching "openapi" as modelName)
117
+ ctx.addRoute('get', `${path}/:modelName`, (req, res) => {
118
+ // Check authorization
119
+ if (authorize && !authorize(req)) {
120
+ return res.status(403).json({ error: 'Forbidden' });
121
+ }
122
+
123
+ const { modelName } = req.params;
124
+ const model = getModel(modelName);
125
+
126
+ if (!model || exclude.includes(modelName)) {
127
+ return res.status(404).json({ error: `Model "${modelName}" not found` });
128
+ }
129
+
130
+ res.json({
131
+ meta: {
132
+ version: '1.0.0',
133
+ generatedAt: new Date().toISOString(),
134
+ },
135
+ model: serializeModel(model, { includeColumns, includeRelations, includeScopes }),
136
+ });
137
+ });
138
+
139
+ if (isDev) {
140
+ console.log(` 📋 Schema Explorer: ${path}`);
141
+ }
142
+ },
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Serialize all models
148
+ * @param {Object} options
149
+ * @returns {Object[]}
150
+ */
151
+ function serializeAllModels(options) {
152
+ const { exclude, includeColumns, includeRelations, includeScopes } = options;
153
+ const models = getAllModels();
154
+ const result = [];
155
+
156
+ for (const [name, model] of models) {
157
+ if (exclude.includes(name)) continue;
158
+ result.push(serializeModel(model, { includeColumns, includeRelations, includeScopes }));
159
+ }
160
+
161
+ // Sort by name
162
+ result.sort((a, b) => a.name.localeCompare(b.name));
163
+
164
+ return result;
165
+ }
166
+
167
+ /**
168
+ * Serialize a single model
169
+ * @param {Object} model - Model definition
170
+ * @param {Object} options
171
+ * @returns {Object}
172
+ */
173
+ function serializeModel(model, options) {
174
+ const { includeColumns, includeRelations, includeScopes } = options;
175
+
176
+ const serialized = {
177
+ name: model.name,
178
+ table: model.table,
179
+ primaryKey: model.primaryKey,
180
+ };
181
+
182
+ // Include columns
183
+ if (includeColumns && model.columns) {
184
+ serialized.columns = serializeColumns(model.columns);
185
+ }
186
+
187
+ // Include relations
188
+ if (includeRelations && model.relations) {
189
+ serialized.relations = serializeRelations(model.relations);
190
+ }
191
+
192
+ // Include scopes
193
+ if (includeScopes && model.scopes) {
194
+ serialized.scopes = {
195
+ softDelete: model.scopes.softDelete || false,
196
+ timestamps: model.scopes.timestamps || false,
197
+ tenant: model.scopes.tenant || null,
198
+ };
199
+ }
200
+
201
+ return serialized;
202
+ }
203
+
204
+ /**
205
+ * Serialize column metadata
206
+ * @param {Map} columns - Columns map
207
+ * @returns {Object[]}
208
+ */
209
+ function serializeColumns(columns) {
210
+ const result = [];
211
+
212
+ for (const [name, meta] of columns) {
213
+ result.push({
214
+ name,
215
+ type: meta.type,
216
+ nullable: meta.nullable || false,
217
+ primary: meta.primary || false,
218
+ autoIncrement: meta.autoIncrement || false,
219
+ unique: meta.unique || false,
220
+ index: meta.index || false,
221
+ default: meta.default,
222
+ maxLength: meta.maxLength,
223
+ precision: meta.precision,
224
+ scale: meta.scale,
225
+ enumValues: meta.enumValues,
226
+ references: meta.references,
227
+ referenceColumn: meta.referenceColumn,
228
+ auto: meta.auto,
229
+ });
230
+ }
231
+
232
+ return result;
233
+ }
234
+
235
+ /**
236
+ * Serialize relation metadata
237
+ * @param {Object} relations - Relations object
238
+ * @returns {Object[]}
239
+ */
240
+ function serializeRelations(relations) {
241
+ const result = [];
242
+
243
+ for (const [name, relation] of Object.entries(relations)) {
244
+ // Resolve the model to get its name
245
+ let relatedModelName = null;
246
+ try {
247
+ const relatedModel = relation.model();
248
+ relatedModelName = relatedModel?.name || null;
249
+ } catch {
250
+ // Model not yet defined
251
+ }
252
+
253
+ result.push({
254
+ name,
255
+ type: relation.type,
256
+ relatedModel: relatedModelName,
257
+ foreignKey: relation.foreignKey,
258
+ localKey: relation.localKey || 'id',
259
+ });
260
+ }
261
+
262
+ return result;
263
+ }
264
+
265
+ /**
266
+ * Generate OpenAPI schemas from models
267
+ * @param {Object} options
268
+ * @returns {Object}
269
+ */
270
+ function generateOpenApiSchemas(options) {
271
+ const { exclude } = options;
272
+ const models = getAllModels();
273
+ const schemas = {};
274
+
275
+ for (const [name, model] of models) {
276
+ if (exclude.includes(name)) continue;
277
+
278
+ const properties = {};
279
+ const required = [];
280
+
281
+ if (model.columns) {
282
+ for (const [colName, meta] of model.columns) {
283
+ properties[colName] = columnToOpenApiType(meta);
284
+
285
+ if (!meta.nullable && !meta.autoIncrement && meta.default === undefined) {
286
+ required.push(colName);
287
+ }
288
+ }
289
+ }
290
+
291
+ schemas[name] = {
292
+ type: 'object',
293
+ properties,
294
+ required: required.length > 0 ? required : undefined,
295
+ };
296
+
297
+ // Add input schema (without auto fields)
298
+ const inputProperties = {};
299
+ const inputRequired = [];
300
+
301
+ if (model.columns) {
302
+ for (const [colName, meta] of model.columns) {
303
+ // Skip auto-generated fields for input
304
+ if (meta.autoIncrement || meta.auto) continue;
305
+
306
+ inputProperties[colName] = columnToOpenApiType(meta);
307
+
308
+ if (!meta.nullable && meta.default === undefined) {
309
+ inputRequired.push(colName);
310
+ }
311
+ }
312
+ }
313
+
314
+ schemas[`${name}Input`] = {
315
+ type: 'object',
316
+ properties: inputProperties,
317
+ required: inputRequired.length > 0 ? inputRequired : undefined,
318
+ };
319
+ }
320
+
321
+ return schemas;
322
+ }
323
+
324
+ /**
325
+ * Convert column metadata to OpenAPI type
326
+ * @param {Object} meta - Column metadata
327
+ * @returns {Object}
328
+ */
329
+ function columnToOpenApiType(meta) {
330
+ const result = {};
331
+
332
+ switch (meta.type) {
333
+ case 'bigint':
334
+ case 'integer':
335
+ result.type = 'integer';
336
+ if (meta.type === 'bigint') result.format = 'int64';
337
+ break;
338
+
339
+ case 'float':
340
+ case 'decimal':
341
+ result.type = 'number';
342
+ if (meta.type === 'decimal') result.format = 'double';
343
+ break;
344
+
345
+ case 'boolean':
346
+ result.type = 'boolean';
347
+ break;
348
+
349
+ case 'date':
350
+ result.type = 'string';
351
+ result.format = 'date';
352
+ break;
353
+
354
+ case 'datetime':
355
+ case 'timestamp':
356
+ result.type = 'string';
357
+ result.format = 'date-time';
358
+ break;
359
+
360
+ case 'uuid':
361
+ result.type = 'string';
362
+ result.format = 'uuid';
363
+ break;
364
+
365
+ case 'json':
366
+ result.type = 'object';
367
+ break;
368
+
369
+ case 'enum':
370
+ result.type = 'string';
371
+ if (meta.enumValues) {
372
+ result.enum = meta.enumValues;
373
+ }
374
+ break;
375
+
376
+ case 'text':
377
+ case 'string':
378
+ default:
379
+ result.type = 'string';
380
+ if (meta.maxLength) {
381
+ result.maxLength = meta.maxLength;
382
+ }
383
+ break;
384
+ }
385
+
386
+ if (meta.nullable) {
387
+ result.nullable = true;
388
+ }
389
+
390
+ if (meta.default !== undefined) {
391
+ result.default = meta.default;
392
+ }
393
+
394
+ return result;
395
+ }
396
+
397
+ module.exports = schemaExplorerPlugin;
398
+
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Webspresso Sitemap Plugin
3
+ * Generates XML sitemap from registered routes
4
+ */
5
+
6
+ const { matchPattern } = require('../src/plugin-manager');
7
+
8
+ /**
9
+ * Escape XML special characters
10
+ */
11
+ function escapeXml(str) {
12
+ if (!str) return '';
13
+ return str
14
+ .replace(/&/g, '&')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;')
18
+ .replace(/'/g, '&apos;');
19
+ }
20
+
21
+ /**
22
+ * Generate sitemap XML content
23
+ */
24
+ function generateSitemapXml(urls, options = {}) {
25
+ const { hostname, defaultChangefreq = 'weekly', defaultPriority = 0.8 } = options;
26
+
27
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
28
+ xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
29
+
30
+ // Add i18n namespace if needed
31
+ if (urls.some(u => u.alternates && u.alternates.length > 0)) {
32
+ xml += ' xmlns:xhtml="http://www.w3.org/1999/xhtml"';
33
+ }
34
+ xml += '>\n';
35
+
36
+ for (const url of urls) {
37
+ xml += ' <url>\n';
38
+ xml += ` <loc>${escapeXml(url.loc)}</loc>\n`;
39
+
40
+ if (url.lastmod) {
41
+ xml += ` <lastmod>${url.lastmod}</lastmod>\n`;
42
+ }
43
+
44
+ xml += ` <changefreq>${url.changefreq || defaultChangefreq}</changefreq>\n`;
45
+ xml += ` <priority>${url.priority || defaultPriority}</priority>\n`;
46
+
47
+ // Add hreflang alternates for i18n
48
+ if (url.alternates && url.alternates.length > 0) {
49
+ for (const alt of url.alternates) {
50
+ xml += ` <xhtml:link rel="alternate" hreflang="${escapeXml(alt.lang)}" href="${escapeXml(alt.href)}"/>\n`;
51
+ }
52
+ }
53
+
54
+ xml += ' </url>\n';
55
+ }
56
+
57
+ xml += '</urlset>';
58
+ return xml;
59
+ }
60
+
61
+ /**
62
+ * Generate robots.txt content
63
+ */
64
+ function generateRobotsTxt(hostname, options = {}) {
65
+ const { sitemapPath = '/sitemap.xml', disallow = [] } = options;
66
+
67
+ let txt = 'User-agent: *\n';
68
+
69
+ for (const path of disallow) {
70
+ txt += `Disallow: ${path}\n`;
71
+ }
72
+
73
+ txt += '\n';
74
+ txt += `Sitemap: ${hostname}${sitemapPath}\n`;
75
+
76
+ return txt;
77
+ }
78
+
79
+ /**
80
+ * Create the sitemap plugin
81
+ * @param {Object} options - Plugin options
82
+ * @param {string} options.hostname - Base URL (e.g., 'https://example.com')
83
+ * @param {Array<string>} options.exclude - Patterns to exclude (e.g., ['/admin/*', '/api/*'])
84
+ * @param {string} options.changefreq - Default change frequency
85
+ * @param {number} options.priority - Default priority (0.0 - 1.0)
86
+ * @param {boolean} options.i18n - Enable i18n hreflang support
87
+ * @param {Array<string>} options.locales - Supported locales (defaults to env SUPPORTED_LOCALES)
88
+ * @param {boolean} options.robots - Generate robots.txt endpoint
89
+ * @param {Array<string>} options.robotsDisallow - Paths to disallow in robots.txt
90
+ */
91
+ function sitemapPlugin(options = {}) {
92
+ const {
93
+ hostname = process.env.BASE_URL || 'http://localhost:3000',
94
+ exclude = ['/api/*'],
95
+ changefreq = 'weekly',
96
+ priority = 0.8,
97
+ i18n = false,
98
+ locales = (process.env.SUPPORTED_LOCALES || 'en').split(','),
99
+ robots = true,
100
+ robotsDisallow = []
101
+ } = options;
102
+
103
+ // Storage for dynamic URLs and exclusions
104
+ const dynamicUrls = [];
105
+ const dynamicExclusions = [...exclude];
106
+
107
+ return {
108
+ name: 'sitemap',
109
+ version: '1.0.0',
110
+ _options: options,
111
+
112
+ /**
113
+ * Public API for other plugins
114
+ */
115
+ api: {
116
+ /**
117
+ * Add a URL to the sitemap
118
+ * @param {string} path - URL path
119
+ * @param {Object} opts - URL options (changefreq, priority, lastmod)
120
+ */
121
+ addUrl(path, opts = {}) {
122
+ dynamicUrls.push({ path, ...opts });
123
+ },
124
+
125
+ /**
126
+ * Exclude a pattern from the sitemap
127
+ * @param {string} pattern - Glob pattern to exclude
128
+ */
129
+ exclude(pattern) {
130
+ dynamicExclusions.push(pattern);
131
+ },
132
+
133
+ /**
134
+ * Get all sitemap URLs
135
+ */
136
+ getUrls() {
137
+ return [...dynamicUrls];
138
+ }
139
+ },
140
+
141
+ /**
142
+ * Called after routes are mounted
143
+ */
144
+ onRoutesReady(ctx) {
145
+ // Filter routes for sitemap
146
+ const sitemapRoutes = ctx.routes
147
+ .filter(r => r.type === 'ssr')
148
+ .filter(r => !r.isDynamic) // Exclude dynamic routes
149
+ .filter(r => {
150
+ // Check exclusion patterns
151
+ for (const pattern of dynamicExclusions) {
152
+ if (matchPattern(r.pattern, pattern)) {
153
+ return false;
154
+ }
155
+ }
156
+ return true;
157
+ });
158
+
159
+ // Build URLs
160
+ const urls = [];
161
+
162
+ for (const route of sitemapRoutes) {
163
+ const baseUrl = `${hostname.replace(/\/$/, '')}${route.pattern}`;
164
+
165
+ const urlEntry = {
166
+ loc: baseUrl,
167
+ changefreq,
168
+ priority
169
+ };
170
+
171
+ // Add i18n alternates
172
+ if (i18n && locales.length > 1) {
173
+ urlEntry.alternates = locales.map(lang => ({
174
+ lang,
175
+ href: `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}lang=${lang}`
176
+ }));
177
+ }
178
+
179
+ urls.push(urlEntry);
180
+ }
181
+
182
+ // Add dynamic URLs
183
+ for (const dynUrl of dynamicUrls) {
184
+ const urlEntry = {
185
+ loc: `${hostname.replace(/\/$/, '')}${dynUrl.path}`,
186
+ changefreq: dynUrl.changefreq || changefreq,
187
+ priority: dynUrl.priority || priority,
188
+ lastmod: dynUrl.lastmod
189
+ };
190
+
191
+ if (i18n && locales.length > 1 && dynUrl.i18n !== false) {
192
+ urlEntry.alternates = locales.map(lang => ({
193
+ lang,
194
+ href: `${urlEntry.loc}${urlEntry.loc.includes('?') ? '&' : '?'}lang=${lang}`
195
+ }));
196
+ }
197
+
198
+ urls.push(urlEntry);
199
+ }
200
+
201
+ // Register sitemap route
202
+ ctx.addRoute('get', '/sitemap.xml', (req, res) => {
203
+ res.type('application/xml');
204
+ res.send(generateSitemapXml(urls, { hostname, defaultChangefreq: changefreq, defaultPriority: priority }));
205
+ });
206
+
207
+ // Register robots.txt route
208
+ if (robots) {
209
+ ctx.addRoute('get', '/robots.txt', (req, res) => {
210
+ res.type('text/plain');
211
+ res.send(generateRobotsTxt(hostname, { disallow: robotsDisallow }));
212
+ });
213
+ }
214
+ }
215
+ };
216
+ }
217
+
218
+ module.exports = sitemapPlugin;
219
+
220
+
221
+