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.
- package/README.md +23 -1
- package/bin/webspresso.js +125 -15
- package/package.json +3 -2
- package/plugins/analytics.js +270 -0
- package/plugins/dashboard/app.js +267 -0
- package/plugins/dashboard/index.js +142 -0
- package/plugins/dashboard/styles.js +384 -0
- package/plugins/index.js +17 -0
- package/plugins/schema-explorer.js +398 -0
- package/plugins/sitemap.js +221 -0
|
@@ -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, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
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
|
+
|