@momentumcms/plugins-seo 0.4.0
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/index.cjs +1665 -0
- package/index.js +1637 -0
- package/lib/seo-admin-routes.cjs +1377 -0
- package/lib/seo-admin-routes.js +1435 -0
- package/lib/seo-field-injector.cjs +312 -0
- package/lib/seo-field-injector.js +285 -0
- package/package.json +54 -0
- package/src/index.d.ts +11 -0
- package/src/lib/analysis/seo-analysis-collection.d.ts +7 -0
- package/src/lib/analysis/seo-analysis-hooks.d.ts +13 -0
- package/src/lib/analysis/seo-analysis.types.d.ts +56 -0
- package/src/lib/analysis/seo-analyzer.d.ts +36 -0
- package/src/lib/dashboard/seo-analysis-handler.d.ts +18 -0
- package/src/lib/meta/meta-builder.d.ts +14 -0
- package/src/lib/meta/meta-handler.d.ts +16 -0
- package/src/lib/robots/robots-handler.d.ts +27 -0
- package/src/lib/robots/robots-txt-generator.d.ts +12 -0
- package/src/lib/seo-admin-routes.d.ts +3 -0
- package/src/lib/seo-config.types.d.ts +174 -0
- package/src/lib/seo-field-injector.d.ts +27 -0
- package/src/lib/seo-fields.d.ts +21 -0
- package/src/lib/seo-plugin.d.ts +29 -0
- package/src/lib/seo-utils.d.ts +21 -0
- package/src/lib/settings/seo-settings-collection.d.ts +9 -0
- package/src/lib/settings/seo-settings-handler.d.ts +21 -0
- package/src/lib/sitemap/sitemap-cache.d.ts +12 -0
- package/src/lib/sitemap/sitemap-generator.d.ts +20 -0
- package/src/lib/sitemap/sitemap-handler.d.ts +28 -0
- package/src/lib/sitemap/sitemap-settings-collection.d.ts +7 -0
- package/src/lib/sitemap/sitemap-settings-handler.d.ts +18 -0
package/index.cjs
ADDED
|
@@ -0,0 +1,1665 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// libs/plugins/seo/src/index.ts
|
|
21
|
+
var src_exports = {};
|
|
22
|
+
__export(src_exports, {
|
|
23
|
+
injectSeoFields: () => injectSeoFields,
|
|
24
|
+
seoPlugin: () => seoPlugin
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(src_exports);
|
|
27
|
+
|
|
28
|
+
// libs/core/src/lib/collections/define-collection.ts
|
|
29
|
+
function defineCollection(config) {
|
|
30
|
+
const collection = {
|
|
31
|
+
timestamps: true,
|
|
32
|
+
// Enable timestamps by default
|
|
33
|
+
...config
|
|
34
|
+
};
|
|
35
|
+
if (!collection.slug) {
|
|
36
|
+
throw new Error("Collection must have a slug");
|
|
37
|
+
}
|
|
38
|
+
if (!collection.fields || collection.fields.length === 0) {
|
|
39
|
+
throw new Error(`Collection "${collection.slug}" must have at least one field`);
|
|
40
|
+
}
|
|
41
|
+
if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return collection;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// libs/core/src/lib/fields/field-builders.ts
|
|
50
|
+
function text(name, options = {}) {
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
type: "text",
|
|
54
|
+
...options
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function textarea(name, options = {}) {
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
type: "textarea",
|
|
61
|
+
...options
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function number(name, options = {}) {
|
|
65
|
+
return {
|
|
66
|
+
name,
|
|
67
|
+
type: "number",
|
|
68
|
+
...options
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function date(name, options = {}) {
|
|
72
|
+
return {
|
|
73
|
+
name,
|
|
74
|
+
type: "date",
|
|
75
|
+
...options
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function checkbox(name, options = {}) {
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
type: "checkbox",
|
|
82
|
+
...options,
|
|
83
|
+
defaultValue: options.defaultValue ?? false
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function select(name, options) {
|
|
87
|
+
return {
|
|
88
|
+
name,
|
|
89
|
+
type: "select",
|
|
90
|
+
...options
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function upload(name, options = {}) {
|
|
94
|
+
return {
|
|
95
|
+
name,
|
|
96
|
+
type: "upload",
|
|
97
|
+
relationTo: options.relationTo ?? "media",
|
|
98
|
+
...options
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function json(name, options = {}) {
|
|
102
|
+
return {
|
|
103
|
+
name,
|
|
104
|
+
type: "json",
|
|
105
|
+
...options
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function tabs(name, options) {
|
|
109
|
+
return {
|
|
110
|
+
name,
|
|
111
|
+
type: "tabs",
|
|
112
|
+
...options
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// libs/core/src/lib/collections/media.collection.ts
|
|
117
|
+
var MediaCollection = defineCollection({
|
|
118
|
+
slug: "media",
|
|
119
|
+
labels: {
|
|
120
|
+
singular: "Media",
|
|
121
|
+
plural: "Media"
|
|
122
|
+
},
|
|
123
|
+
upload: {
|
|
124
|
+
mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
|
|
125
|
+
},
|
|
126
|
+
admin: {
|
|
127
|
+
useAsTitle: "filename",
|
|
128
|
+
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
129
|
+
},
|
|
130
|
+
fields: [
|
|
131
|
+
text("filename", {
|
|
132
|
+
required: true,
|
|
133
|
+
label: "Filename",
|
|
134
|
+
description: "Original filename of the uploaded file"
|
|
135
|
+
}),
|
|
136
|
+
text("mimeType", {
|
|
137
|
+
required: true,
|
|
138
|
+
label: "MIME Type",
|
|
139
|
+
description: "File MIME type (e.g., image/jpeg, application/pdf)"
|
|
140
|
+
}),
|
|
141
|
+
number("filesize", {
|
|
142
|
+
label: "File Size",
|
|
143
|
+
description: "File size in bytes"
|
|
144
|
+
}),
|
|
145
|
+
text("path", {
|
|
146
|
+
label: "Storage Path",
|
|
147
|
+
description: "Path/key where the file is stored",
|
|
148
|
+
admin: {
|
|
149
|
+
hidden: true
|
|
150
|
+
}
|
|
151
|
+
}),
|
|
152
|
+
text("url", {
|
|
153
|
+
label: "URL",
|
|
154
|
+
description: "Public URL to access the file"
|
|
155
|
+
}),
|
|
156
|
+
text("alt", {
|
|
157
|
+
label: "Alt Text",
|
|
158
|
+
description: "Alternative text for accessibility"
|
|
159
|
+
}),
|
|
160
|
+
number("width", {
|
|
161
|
+
label: "Width",
|
|
162
|
+
description: "Image width in pixels (for images only)"
|
|
163
|
+
}),
|
|
164
|
+
number("height", {
|
|
165
|
+
label: "Height",
|
|
166
|
+
description: "Image height in pixels (for images only)"
|
|
167
|
+
}),
|
|
168
|
+
json("focalPoint", {
|
|
169
|
+
label: "Focal Point",
|
|
170
|
+
description: "Focal point coordinates for image cropping",
|
|
171
|
+
admin: {
|
|
172
|
+
hidden: true
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
],
|
|
176
|
+
access: {
|
|
177
|
+
// Media is readable by anyone by default
|
|
178
|
+
read: () => true,
|
|
179
|
+
// Only authenticated users can create/update/delete
|
|
180
|
+
create: ({ req }) => !!req?.user,
|
|
181
|
+
update: ({ req }) => !!req?.user,
|
|
182
|
+
delete: ({ req }) => !!req?.user
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// libs/core/src/lib/access/access-helpers.ts
|
|
187
|
+
function hasRole(role) {
|
|
188
|
+
return ({ req }) => req.user?.role === role;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// libs/plugins/seo/src/lib/seo-fields.ts
|
|
192
|
+
function getSeoFields() {
|
|
193
|
+
return [
|
|
194
|
+
text("metaTitle", {
|
|
195
|
+
label: "Meta Title",
|
|
196
|
+
maxLength: 70,
|
|
197
|
+
description: "Title tag for search engines (50-60 chars recommended)"
|
|
198
|
+
}),
|
|
199
|
+
textarea("metaDescription", {
|
|
200
|
+
label: "Meta Description",
|
|
201
|
+
maxLength: 160,
|
|
202
|
+
rows: 3,
|
|
203
|
+
description: "Description for search results (120-155 chars recommended)"
|
|
204
|
+
}),
|
|
205
|
+
text("canonicalUrl", {
|
|
206
|
+
label: "Canonical URL",
|
|
207
|
+
description: "Override the canonical URL for this page"
|
|
208
|
+
}),
|
|
209
|
+
text("focusKeyword", {
|
|
210
|
+
label: "Focus Keyword",
|
|
211
|
+
description: "Primary keyword to optimize this content for"
|
|
212
|
+
}),
|
|
213
|
+
text("ogTitle", {
|
|
214
|
+
label: "OG Title",
|
|
215
|
+
description: "Open Graph title (falls back to Meta Title)"
|
|
216
|
+
}),
|
|
217
|
+
textarea("ogDescription", {
|
|
218
|
+
label: "OG Description",
|
|
219
|
+
rows: 2,
|
|
220
|
+
description: "Open Graph description (falls back to Meta Description)"
|
|
221
|
+
}),
|
|
222
|
+
upload("ogImage", {
|
|
223
|
+
label: "OG Image",
|
|
224
|
+
relationTo: "media",
|
|
225
|
+
mimeTypes: ["image/*"],
|
|
226
|
+
description: "Recommended size: 1200x630px"
|
|
227
|
+
}),
|
|
228
|
+
select("ogType", {
|
|
229
|
+
label: "OG Type",
|
|
230
|
+
options: [
|
|
231
|
+
{ label: "Website", value: "website" },
|
|
232
|
+
{ label: "Article", value: "article" },
|
|
233
|
+
{ label: "Product", value: "product" },
|
|
234
|
+
{ label: "Profile", value: "profile" }
|
|
235
|
+
],
|
|
236
|
+
defaultValue: "website"
|
|
237
|
+
}),
|
|
238
|
+
select("twitterCard", {
|
|
239
|
+
label: "Twitter Card",
|
|
240
|
+
options: [
|
|
241
|
+
{ label: "Summary", value: "summary" },
|
|
242
|
+
{ label: "Summary Large Image", value: "summary_large_image" },
|
|
243
|
+
{ label: "Player", value: "player" },
|
|
244
|
+
{ label: "App", value: "app" }
|
|
245
|
+
],
|
|
246
|
+
defaultValue: "summary_large_image"
|
|
247
|
+
}),
|
|
248
|
+
checkbox("noIndex", {
|
|
249
|
+
label: "No Index",
|
|
250
|
+
description: "Tell search engines not to index this page"
|
|
251
|
+
}),
|
|
252
|
+
checkbox("noFollow", {
|
|
253
|
+
label: "No Follow",
|
|
254
|
+
description: "Tell search engines not to follow links on this page"
|
|
255
|
+
}),
|
|
256
|
+
checkbox("excludeFromSitemap", {
|
|
257
|
+
label: "Exclude from Sitemap",
|
|
258
|
+
description: "Exclude this page from the XML sitemap without affecting search engine indexing"
|
|
259
|
+
}),
|
|
260
|
+
json("structuredData", {
|
|
261
|
+
label: "Structured Data (JSON-LD)",
|
|
262
|
+
description: "Custom JSON-LD structured data for this page"
|
|
263
|
+
})
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
function createSeoTabConfig() {
|
|
267
|
+
return {
|
|
268
|
+
name: "seo",
|
|
269
|
+
label: "SEO",
|
|
270
|
+
description: "Search engine optimization settings",
|
|
271
|
+
fields: getSeoFields()
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// libs/plugins/seo/src/lib/seo-utils.ts
|
|
276
|
+
function hasSeoField(collection) {
|
|
277
|
+
for (const field of collection.fields) {
|
|
278
|
+
if (field.name === "seo" && field.type === "group")
|
|
279
|
+
return true;
|
|
280
|
+
if (field.type === "tabs") {
|
|
281
|
+
const tabsField = field;
|
|
282
|
+
if (tabsField.tabs.some((t) => t.name === "seo"))
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
function extractSeoFieldData(doc) {
|
|
289
|
+
const raw = doc["seo"];
|
|
290
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
291
|
+
return raw;
|
|
292
|
+
}
|
|
293
|
+
return {};
|
|
294
|
+
}
|
|
295
|
+
function computeGrade(score) {
|
|
296
|
+
if (score >= 70)
|
|
297
|
+
return "good";
|
|
298
|
+
if (score >= 40)
|
|
299
|
+
return "warning";
|
|
300
|
+
return "poor";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// libs/plugins/seo/src/lib/seo-field-injector.ts
|
|
304
|
+
function shouldInject(collection, options) {
|
|
305
|
+
if (collection.managed)
|
|
306
|
+
return false;
|
|
307
|
+
if (collection.slug.startsWith("seo-"))
|
|
308
|
+
return false;
|
|
309
|
+
if (options.collections === "*") {
|
|
310
|
+
return !(options.excludeCollections ?? []).includes(collection.slug);
|
|
311
|
+
}
|
|
312
|
+
return options.collections.includes(collection.slug);
|
|
313
|
+
}
|
|
314
|
+
function findTopLevelTabs(collection) {
|
|
315
|
+
return collection.fields.find((f) => f.type === "tabs");
|
|
316
|
+
}
|
|
317
|
+
function injectSeoFields(collections, options) {
|
|
318
|
+
const seoTab = createSeoTabConfig();
|
|
319
|
+
for (const collection of collections) {
|
|
320
|
+
if (!shouldInject(collection, options))
|
|
321
|
+
continue;
|
|
322
|
+
if (hasSeoField(collection))
|
|
323
|
+
continue;
|
|
324
|
+
const existingTabs = findTopLevelTabs(collection);
|
|
325
|
+
if (existingTabs) {
|
|
326
|
+
existingTabs.tabs.push(seoTab);
|
|
327
|
+
} else {
|
|
328
|
+
const originalFields = [...collection.fields];
|
|
329
|
+
const tabsField = tabs("seoTabs", {
|
|
330
|
+
tabs: [{ label: "Content", fields: originalFields }, seoTab]
|
|
331
|
+
});
|
|
332
|
+
collection.fields = [tabsField];
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// libs/plugins/seo/src/lib/analysis/seo-analysis-collection.ts
|
|
338
|
+
function internalOrAdmin({ req }) {
|
|
339
|
+
if (!req.user)
|
|
340
|
+
return true;
|
|
341
|
+
return req.user.role === "admin";
|
|
342
|
+
}
|
|
343
|
+
var SeoAnalysis = defineCollection({
|
|
344
|
+
slug: "seo-analysis",
|
|
345
|
+
managed: true,
|
|
346
|
+
labels: { singular: "SEO Analysis", plural: "SEO Analyses" },
|
|
347
|
+
admin: {
|
|
348
|
+
group: "SEO",
|
|
349
|
+
hidden: true,
|
|
350
|
+
useAsTitle: "documentId"
|
|
351
|
+
},
|
|
352
|
+
fields: [
|
|
353
|
+
text("collection", { required: true, label: "Collection" }),
|
|
354
|
+
text("documentId", { required: true, label: "Document ID" }),
|
|
355
|
+
number("score", { required: true, label: "Score", min: 0, max: 100 }),
|
|
356
|
+
text("grade", { required: true, label: "Grade" }),
|
|
357
|
+
json("rules", { label: "Rule Results" }),
|
|
358
|
+
text("focusKeyword", { label: "Focus Keyword" }),
|
|
359
|
+
date("analyzedAt", { required: true, label: "Analyzed At" })
|
|
360
|
+
],
|
|
361
|
+
access: {
|
|
362
|
+
read: internalOrAdmin,
|
|
363
|
+
create: internalOrAdmin,
|
|
364
|
+
update: internalOrAdmin,
|
|
365
|
+
delete: hasRole("admin")
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// libs/plugins/seo/src/lib/analysis/seo-analyzer.ts
|
|
370
|
+
var HTML_TAG_RE = /<[^>]*>/g;
|
|
371
|
+
var HEADING_RE_SOURCE = "<h([1-6])[^>]*>(.*?)</h[1-6]>";
|
|
372
|
+
var WHITESPACE_RE = /\s+/g;
|
|
373
|
+
function extractTextContent(doc) {
|
|
374
|
+
const parts = [];
|
|
375
|
+
for (const value of Object.values(doc)) {
|
|
376
|
+
if (typeof value === "string") {
|
|
377
|
+
parts.push(value.replace(HTML_TAG_RE, " "));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return parts.join(" ").replace(WHITESPACE_RE, " ").trim();
|
|
381
|
+
}
|
|
382
|
+
function extractHeadings(doc) {
|
|
383
|
+
const headings = [];
|
|
384
|
+
for (const value of Object.values(doc)) {
|
|
385
|
+
if (typeof value !== "string")
|
|
386
|
+
continue;
|
|
387
|
+
const re = new RegExp(HEADING_RE_SOURCE, "gi");
|
|
388
|
+
let match;
|
|
389
|
+
while ((match = re.exec(value)) !== null) {
|
|
390
|
+
headings.push({
|
|
391
|
+
level: parseInt(match[1], 10),
|
|
392
|
+
text: match[2].replace(HTML_TAG_RE, "").trim()
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return headings;
|
|
397
|
+
}
|
|
398
|
+
function clamp(value, min, max) {
|
|
399
|
+
return Math.max(min, Math.min(max, value));
|
|
400
|
+
}
|
|
401
|
+
function rangeScore(value, optMin, optMax, absMin, absMax) {
|
|
402
|
+
if (value >= optMin && value <= optMax)
|
|
403
|
+
return 100;
|
|
404
|
+
if (value < absMin || value > absMax)
|
|
405
|
+
return 0;
|
|
406
|
+
if (value < optMin) {
|
|
407
|
+
return Math.round((value - absMin) / (optMin - absMin) * 100);
|
|
408
|
+
}
|
|
409
|
+
return Math.round((absMax - value) / (absMax - optMax) * 100);
|
|
410
|
+
}
|
|
411
|
+
function createTitleLengthRule(config) {
|
|
412
|
+
const min = config?.titleLength?.min ?? 50;
|
|
413
|
+
const max = config?.titleLength?.max ?? 60;
|
|
414
|
+
return {
|
|
415
|
+
id: "title-length",
|
|
416
|
+
name: "Title Length",
|
|
417
|
+
weight: 15,
|
|
418
|
+
score: (ctx) => {
|
|
419
|
+
const title = ctx.seo.metaTitle ?? "";
|
|
420
|
+
if (!title)
|
|
421
|
+
return 0;
|
|
422
|
+
return rangeScore(title.length, min, max, 20, 70);
|
|
423
|
+
},
|
|
424
|
+
recommendation: (ctx) => {
|
|
425
|
+
const title = ctx.seo.metaTitle ?? "";
|
|
426
|
+
if (!title)
|
|
427
|
+
return "Add a meta title for search engines";
|
|
428
|
+
if (title.length < min)
|
|
429
|
+
return `Title is too short (${title.length} chars). Aim for ${min}-${max} characters.`;
|
|
430
|
+
if (title.length > max)
|
|
431
|
+
return `Title is too long (${title.length} chars). Aim for ${min}-${max} characters.`;
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
function createDescriptionLengthRule(config) {
|
|
437
|
+
const min = config?.descriptionLength?.min ?? 120;
|
|
438
|
+
const max = config?.descriptionLength?.max ?? 155;
|
|
439
|
+
return {
|
|
440
|
+
id: "description-length",
|
|
441
|
+
name: "Description Length",
|
|
442
|
+
weight: 15,
|
|
443
|
+
score: (ctx) => {
|
|
444
|
+
const desc = ctx.seo.metaDescription ?? "";
|
|
445
|
+
if (!desc)
|
|
446
|
+
return 0;
|
|
447
|
+
return rangeScore(desc.length, min, max, 50, 160);
|
|
448
|
+
},
|
|
449
|
+
recommendation: (ctx) => {
|
|
450
|
+
const desc = ctx.seo.metaDescription ?? "";
|
|
451
|
+
if (!desc)
|
|
452
|
+
return "Add a meta description for search results";
|
|
453
|
+
if (desc.length < min)
|
|
454
|
+
return `Description is too short (${desc.length} chars). Aim for ${min}-${max} characters.`;
|
|
455
|
+
if (desc.length > max)
|
|
456
|
+
return `Description is too long (${desc.length} chars). Aim for ${min}-${max} characters.`;
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function createKeywordInTitleRule() {
|
|
462
|
+
return {
|
|
463
|
+
id: "keyword-in-title",
|
|
464
|
+
name: "Keyword in Title",
|
|
465
|
+
weight: 15,
|
|
466
|
+
score: (ctx) => {
|
|
467
|
+
const keyword = ctx.seo.focusKeyword;
|
|
468
|
+
if (!keyword)
|
|
469
|
+
return 100;
|
|
470
|
+
const title = ctx.seo.metaTitle ?? "";
|
|
471
|
+
return title.toLowerCase().includes(keyword.toLowerCase()) ? 100 : 0;
|
|
472
|
+
},
|
|
473
|
+
recommendation: (ctx) => {
|
|
474
|
+
const keyword = ctx.seo.focusKeyword;
|
|
475
|
+
if (!keyword)
|
|
476
|
+
return null;
|
|
477
|
+
const title = ctx.seo.metaTitle ?? "";
|
|
478
|
+
if (!title.toLowerCase().includes(keyword.toLowerCase())) {
|
|
479
|
+
return `Include your focus keyword "${keyword}" in the meta title`;
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
function createKeywordInDescriptionRule() {
|
|
486
|
+
return {
|
|
487
|
+
id: "keyword-in-description",
|
|
488
|
+
name: "Keyword in Description",
|
|
489
|
+
weight: 10,
|
|
490
|
+
score: (ctx) => {
|
|
491
|
+
const keyword = ctx.seo.focusKeyword;
|
|
492
|
+
if (!keyword)
|
|
493
|
+
return 100;
|
|
494
|
+
const desc = ctx.seo.metaDescription ?? "";
|
|
495
|
+
return desc.toLowerCase().includes(keyword.toLowerCase()) ? 100 : 0;
|
|
496
|
+
},
|
|
497
|
+
recommendation: (ctx) => {
|
|
498
|
+
const keyword = ctx.seo.focusKeyword;
|
|
499
|
+
if (!keyword)
|
|
500
|
+
return null;
|
|
501
|
+
const desc = ctx.seo.metaDescription ?? "";
|
|
502
|
+
if (!desc.toLowerCase().includes(keyword.toLowerCase())) {
|
|
503
|
+
return `Include your focus keyword "${keyword}" in the meta description`;
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
function createKeywordDensityRule(config) {
|
|
510
|
+
const minDensity = config?.keywordDensity?.min ?? 1;
|
|
511
|
+
const maxDensity = config?.keywordDensity?.max ?? 3;
|
|
512
|
+
return {
|
|
513
|
+
id: "keyword-density",
|
|
514
|
+
name: "Keyword Density",
|
|
515
|
+
weight: 10,
|
|
516
|
+
score: (ctx) => {
|
|
517
|
+
const keyword = ctx.seo.focusKeyword;
|
|
518
|
+
if (!keyword)
|
|
519
|
+
return 100;
|
|
520
|
+
if (!ctx.textContent)
|
|
521
|
+
return 0;
|
|
522
|
+
const words = ctx.textContent.split(/\s+/).length;
|
|
523
|
+
if (words === 0)
|
|
524
|
+
return 0;
|
|
525
|
+
const keywordLower = keyword.toLowerCase();
|
|
526
|
+
const textLower = ctx.textContent.toLowerCase();
|
|
527
|
+
let count = 0;
|
|
528
|
+
let pos = 0;
|
|
529
|
+
while ((pos = textLower.indexOf(keywordLower, pos)) !== -1) {
|
|
530
|
+
count++;
|
|
531
|
+
pos += keywordLower.length;
|
|
532
|
+
}
|
|
533
|
+
const density = count / words * 100;
|
|
534
|
+
if (density >= minDensity && density <= maxDensity)
|
|
535
|
+
return 100;
|
|
536
|
+
if (density === 0)
|
|
537
|
+
return 0;
|
|
538
|
+
if (density < minDensity)
|
|
539
|
+
return clamp(Math.round(density / minDensity * 100), 0, 100);
|
|
540
|
+
return clamp(Math.round((maxDensity * 2 - density) / maxDensity * 100), 0, 100);
|
|
541
|
+
},
|
|
542
|
+
recommendation: (ctx) => {
|
|
543
|
+
const keyword = ctx.seo.focusKeyword;
|
|
544
|
+
if (!keyword || !ctx.textContent)
|
|
545
|
+
return keyword ? "Add content with your focus keyword" : null;
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function createHeadingStructureRule() {
|
|
551
|
+
return {
|
|
552
|
+
id: "heading-structure",
|
|
553
|
+
name: "Heading Structure",
|
|
554
|
+
weight: 10,
|
|
555
|
+
score: (ctx) => {
|
|
556
|
+
if (ctx.headings.length === 0)
|
|
557
|
+
return 0;
|
|
558
|
+
const hasH1 = ctx.headings.some((h) => h.level === 1);
|
|
559
|
+
return hasH1 ? 100 : 50;
|
|
560
|
+
},
|
|
561
|
+
recommendation: (ctx) => {
|
|
562
|
+
if (ctx.headings.length === 0)
|
|
563
|
+
return "Add headings to structure your content";
|
|
564
|
+
if (!ctx.headings.some((h) => h.level === 1))
|
|
565
|
+
return "Add an H1 heading to your content";
|
|
566
|
+
return null;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
function createOgDataRule() {
|
|
571
|
+
return {
|
|
572
|
+
id: "og-data-complete",
|
|
573
|
+
name: "Open Graph Data",
|
|
574
|
+
weight: 10,
|
|
575
|
+
score: (ctx) => {
|
|
576
|
+
let score = 0;
|
|
577
|
+
const ogTitle = ctx.seo.ogTitle ?? ctx.seo.metaTitle;
|
|
578
|
+
const ogDesc = ctx.seo.ogDescription ?? ctx.seo.metaDescription;
|
|
579
|
+
const ogImage = ctx.seo.ogImage;
|
|
580
|
+
if (ogTitle)
|
|
581
|
+
score += 33;
|
|
582
|
+
if (ogDesc)
|
|
583
|
+
score += 33;
|
|
584
|
+
if (ogImage)
|
|
585
|
+
score += 34;
|
|
586
|
+
return score;
|
|
587
|
+
},
|
|
588
|
+
recommendation: (ctx) => {
|
|
589
|
+
const missing = [];
|
|
590
|
+
if (!ctx.seo.ogTitle && !ctx.seo.metaTitle)
|
|
591
|
+
missing.push("OG Title");
|
|
592
|
+
if (!ctx.seo.ogDescription && !ctx.seo.metaDescription)
|
|
593
|
+
missing.push("OG Description");
|
|
594
|
+
if (!ctx.seo.ogImage)
|
|
595
|
+
missing.push("OG Image");
|
|
596
|
+
return missing.length > 0 ? `Missing Open Graph data: ${missing.join(", ")}` : null;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
function createCanonicalUrlRule() {
|
|
601
|
+
return {
|
|
602
|
+
id: "canonical-url",
|
|
603
|
+
name: "Canonical URL",
|
|
604
|
+
weight: 5,
|
|
605
|
+
score: (ctx) => ctx.seo.canonicalUrl ? 100 : 0,
|
|
606
|
+
recommendation: (ctx) => ctx.seo.canonicalUrl ? null : "Set a canonical URL to prevent duplicate content issues"
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function createContentLengthRule() {
|
|
610
|
+
return {
|
|
611
|
+
id: "content-length",
|
|
612
|
+
name: "Content Length",
|
|
613
|
+
weight: 5,
|
|
614
|
+
score: (ctx) => {
|
|
615
|
+
const words = ctx.textContent.split(/\s+/).filter(Boolean).length;
|
|
616
|
+
if (words >= 300)
|
|
617
|
+
return 100;
|
|
618
|
+
if (words >= 150)
|
|
619
|
+
return 50;
|
|
620
|
+
return 0;
|
|
621
|
+
},
|
|
622
|
+
recommendation: (ctx) => {
|
|
623
|
+
const words = ctx.textContent.split(/\s+/).filter(Boolean).length;
|
|
624
|
+
if (words < 300)
|
|
625
|
+
return `Content is short (${words} words). Aim for at least 300 words.`;
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
function createNoIndexWarningRule() {
|
|
631
|
+
return {
|
|
632
|
+
id: "no-index-warning",
|
|
633
|
+
name: "No Index Check",
|
|
634
|
+
weight: 5,
|
|
635
|
+
score: (ctx) => ctx.seo.noIndex ? 0 : 100,
|
|
636
|
+
recommendation: (ctx) => ctx.seo.noIndex ? "This page is set to noindex \u2014 search engines will not index it" : null
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
function buildDefaultRules(config) {
|
|
640
|
+
return [
|
|
641
|
+
createTitleLengthRule(config),
|
|
642
|
+
createDescriptionLengthRule(config),
|
|
643
|
+
createKeywordInTitleRule(),
|
|
644
|
+
createKeywordInDescriptionRule(),
|
|
645
|
+
createKeywordDensityRule(config),
|
|
646
|
+
createHeadingStructureRule(),
|
|
647
|
+
createOgDataRule(),
|
|
648
|
+
createCanonicalUrlRule(),
|
|
649
|
+
createContentLengthRule(),
|
|
650
|
+
createNoIndexWarningRule()
|
|
651
|
+
];
|
|
652
|
+
}
|
|
653
|
+
function analyzeSeo(context, rules) {
|
|
654
|
+
const totalWeight = rules.reduce((sum, r) => sum + r.weight, 0);
|
|
655
|
+
const ruleResults = rules.map((rule) => {
|
|
656
|
+
const score = clamp(rule.score(context), 0, 100);
|
|
657
|
+
return {
|
|
658
|
+
id: rule.id,
|
|
659
|
+
name: rule.name,
|
|
660
|
+
score,
|
|
661
|
+
weight: rule.weight,
|
|
662
|
+
recommendation: score < 100 ? rule.recommendation(context) : null
|
|
663
|
+
};
|
|
664
|
+
});
|
|
665
|
+
const weightedSum = ruleResults.reduce((sum, r) => sum + r.score * r.weight, 0);
|
|
666
|
+
const overallScore = totalWeight > 0 ? Math.round(weightedSum / totalWeight) : 0;
|
|
667
|
+
return {
|
|
668
|
+
score: overallScore,
|
|
669
|
+
grade: computeGrade(overallScore),
|
|
670
|
+
rules: ruleResults
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// libs/plugins/seo/src/lib/analysis/seo-analysis-hooks.ts
|
|
675
|
+
function shouldExclude(collection, config) {
|
|
676
|
+
return (config.excludeCollections ?? []).includes(collection.slug);
|
|
677
|
+
}
|
|
678
|
+
var HOOK_MARKER = Symbol("seo-analysis-hook");
|
|
679
|
+
function isMarkedHook(h) {
|
|
680
|
+
return HOOK_MARKER in h;
|
|
681
|
+
}
|
|
682
|
+
function hasAnalysisHook(collection) {
|
|
683
|
+
return Array.isArray(collection.hooks?.afterChange) && collection.hooks.afterChange.some((h) => isMarkedHook(h) && h[HOOK_MARKER] === true);
|
|
684
|
+
}
|
|
685
|
+
async function analyzeSeoAsync(api, collectionSlug, doc, config) {
|
|
686
|
+
const seoData = extractSeoFieldData(doc);
|
|
687
|
+
const docId = String(doc["id"] ?? "");
|
|
688
|
+
if (!docId)
|
|
689
|
+
return;
|
|
690
|
+
const rules = buildDefaultRules(config);
|
|
691
|
+
if (config.rules) {
|
|
692
|
+
rules.push(...config.rules);
|
|
693
|
+
}
|
|
694
|
+
const textContent = extractTextContent(doc);
|
|
695
|
+
const headings = extractHeadings(doc);
|
|
696
|
+
const result = analyzeSeo(
|
|
697
|
+
{
|
|
698
|
+
seo: seoData,
|
|
699
|
+
doc,
|
|
700
|
+
collection: collectionSlug,
|
|
701
|
+
textContent,
|
|
702
|
+
headings
|
|
703
|
+
},
|
|
704
|
+
rules
|
|
705
|
+
);
|
|
706
|
+
const analysisApi = api.collection("seo-analysis");
|
|
707
|
+
if (!analysisApi.find || !analysisApi.create)
|
|
708
|
+
return;
|
|
709
|
+
const existing = await analysisApi.find({
|
|
710
|
+
where: {
|
|
711
|
+
documentId: { equals: docId },
|
|
712
|
+
collection: { equals: collectionSlug }
|
|
713
|
+
},
|
|
714
|
+
limit: 1
|
|
715
|
+
});
|
|
716
|
+
const data = {
|
|
717
|
+
collection: collectionSlug,
|
|
718
|
+
documentId: docId,
|
|
719
|
+
score: result.score,
|
|
720
|
+
grade: result.grade,
|
|
721
|
+
rules: result.rules,
|
|
722
|
+
focusKeyword: seoData.focusKeyword ?? null,
|
|
723
|
+
analyzedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
724
|
+
};
|
|
725
|
+
if (existing.docs.length > 0 && analysisApi.update) {
|
|
726
|
+
await analysisApi.update(String(existing.docs[0]["id"]), data);
|
|
727
|
+
} else {
|
|
728
|
+
await analysisApi.create(data);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function injectSeoAnalysisHooks(collections, config, getApi) {
|
|
732
|
+
for (const collection of collections) {
|
|
733
|
+
if (!hasSeoField(collection))
|
|
734
|
+
continue;
|
|
735
|
+
if (shouldExclude(collection, config))
|
|
736
|
+
continue;
|
|
737
|
+
if (hasAnalysisHook(collection))
|
|
738
|
+
continue;
|
|
739
|
+
collection.hooks = collection.hooks ?? {};
|
|
740
|
+
const existingAfterChange = collection.hooks.afterChange ?? [];
|
|
741
|
+
const afterChangeHook = (args) => {
|
|
742
|
+
const api = getApi();
|
|
743
|
+
if (!api)
|
|
744
|
+
return;
|
|
745
|
+
const doc = args.doc ?? args.data;
|
|
746
|
+
if (!doc)
|
|
747
|
+
return;
|
|
748
|
+
void analyzeSeoAsync(api, collection.slug, doc, config).catch(() => {
|
|
749
|
+
});
|
|
750
|
+
};
|
|
751
|
+
Object.defineProperty(afterChangeHook, HOOK_MARKER, { value: true });
|
|
752
|
+
collection.hooks.afterChange = [...existingAfterChange, afterChangeHook];
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// libs/plugins/seo/src/lib/sitemap/sitemap-handler.ts
|
|
757
|
+
var import_express = require("express");
|
|
758
|
+
|
|
759
|
+
// libs/plugins/seo/src/lib/sitemap/sitemap-generator.ts
|
|
760
|
+
function escapeXml(str) {
|
|
761
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
762
|
+
}
|
|
763
|
+
function generateSitemap(entries) {
|
|
764
|
+
const urls = entries.map((entry) => {
|
|
765
|
+
const parts = [` <loc>${escapeXml(entry.loc)}</loc>`];
|
|
766
|
+
if (entry.lastmod)
|
|
767
|
+
parts.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
|
|
768
|
+
if (entry.changefreq)
|
|
769
|
+
parts.push(` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`);
|
|
770
|
+
if (entry.priority != null)
|
|
771
|
+
parts.push(` <priority>${entry.priority.toFixed(1)}</priority>`);
|
|
772
|
+
return ` <url>
|
|
773
|
+
${parts.join("\n")}
|
|
774
|
+
</url>`;
|
|
775
|
+
}).join("\n");
|
|
776
|
+
return [
|
|
777
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
778
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
779
|
+
urls,
|
|
780
|
+
"</urlset>"
|
|
781
|
+
].join("\n");
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// libs/plugins/seo/src/lib/sitemap/sitemap-cache.ts
|
|
785
|
+
var SitemapCache = class {
|
|
786
|
+
constructor(defaultTtl = 3e5) {
|
|
787
|
+
this.store = /* @__PURE__ */ new Map();
|
|
788
|
+
this.defaultTtl = defaultTtl;
|
|
789
|
+
}
|
|
790
|
+
get(key) {
|
|
791
|
+
const entry = this.store.get(key);
|
|
792
|
+
if (!entry)
|
|
793
|
+
return null;
|
|
794
|
+
if (Date.now() > entry.expiresAt) {
|
|
795
|
+
this.store.delete(key);
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
return entry.value;
|
|
799
|
+
}
|
|
800
|
+
set(key, value, ttl) {
|
|
801
|
+
this.store.set(key, {
|
|
802
|
+
value,
|
|
803
|
+
expiresAt: Date.now() + (ttl ?? this.defaultTtl)
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
invalidate(key) {
|
|
807
|
+
this.store.delete(key);
|
|
808
|
+
}
|
|
809
|
+
clear() {
|
|
810
|
+
this.store.clear();
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
// libs/plugins/seo/src/lib/sitemap/sitemap-handler.ts
|
|
815
|
+
function resolveChangeFreq(config, slug2) {
|
|
816
|
+
return config.changeFreqs?.[slug2] ?? config.defaultChangeFreq ?? "weekly";
|
|
817
|
+
}
|
|
818
|
+
async function fetchSitemapSettings(api) {
|
|
819
|
+
const map = /* @__PURE__ */ new Map();
|
|
820
|
+
try {
|
|
821
|
+
const settingsApi = api.collection("seo-sitemap-settings");
|
|
822
|
+
if (!settingsApi.find)
|
|
823
|
+
return map;
|
|
824
|
+
const result = await settingsApi.find({ limit: 0 });
|
|
825
|
+
for (const doc of result.docs) {
|
|
826
|
+
if (typeof doc["collection"] === "string") {
|
|
827
|
+
map.set(doc["collection"], {
|
|
828
|
+
includeInSitemap: doc["includeInSitemap"] !== false,
|
|
829
|
+
priority: doc["priority"] != null ? Number(doc["priority"]) : null,
|
|
830
|
+
changeFreq: typeof doc["changeFreq"] === "string" ? doc["changeFreq"] : null
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
} catch {
|
|
835
|
+
}
|
|
836
|
+
return map;
|
|
837
|
+
}
|
|
838
|
+
function createSitemapRouter(options) {
|
|
839
|
+
const { getApi, siteUrl, config, seoCollections } = options;
|
|
840
|
+
const cache = new SitemapCache(config.cacheTtl ?? 3e5);
|
|
841
|
+
const router = (0, import_express.Router)();
|
|
842
|
+
router.get("/sitemap.xml", async (req, res) => {
|
|
843
|
+
const api = getApi();
|
|
844
|
+
if (!api) {
|
|
845
|
+
res.status(503).json({ error: "API not ready" });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
const host = req.get("host");
|
|
849
|
+
const effectiveSiteUrl = host ? `${req.protocol}://${host}` : siteUrl || "http://localhost";
|
|
850
|
+
const cached = cache.get("sitemap");
|
|
851
|
+
if (cached) {
|
|
852
|
+
res.set("Content-Type", "application/xml");
|
|
853
|
+
res.send(cached);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
const entries = [];
|
|
858
|
+
const collections = config.includeCollections ?? seoCollections;
|
|
859
|
+
const settingsMap = await fetchSitemapSettings(api);
|
|
860
|
+
for (const slug2 of collections) {
|
|
861
|
+
if (config.excludeCollections?.includes(slug2))
|
|
862
|
+
continue;
|
|
863
|
+
const collSettings = settingsMap.get(slug2);
|
|
864
|
+
if (collSettings?.includeInSitemap === false)
|
|
865
|
+
continue;
|
|
866
|
+
try {
|
|
867
|
+
const collectionApi = api.collection(slug2);
|
|
868
|
+
if (!collectionApi.find)
|
|
869
|
+
continue;
|
|
870
|
+
const result = await collectionApi.find({ limit: 0 });
|
|
871
|
+
for (const doc of result.docs) {
|
|
872
|
+
const seoData = extractSeoFieldData(doc);
|
|
873
|
+
if (seoData.noIndex)
|
|
874
|
+
continue;
|
|
875
|
+
if (seoData.excludeFromSitemap)
|
|
876
|
+
continue;
|
|
877
|
+
const identifier = typeof doc["slug"] === "string" ? doc["slug"] : String(doc["id"]);
|
|
878
|
+
const loc = config.urlBuilder ? config.urlBuilder(slug2, doc) : `${effectiveSiteUrl}/${slug2}/${identifier}`;
|
|
879
|
+
if (!loc)
|
|
880
|
+
continue;
|
|
881
|
+
const priority = (collSettings?.priority != null ? collSettings.priority : null) ?? config.priorities?.[slug2] ?? config.defaultPriority ?? 0.5;
|
|
882
|
+
const changefreq = (
|
|
883
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- DB string narrowed to changefreq union
|
|
884
|
+
collSettings?.changeFreq ?? resolveChangeFreq(config, slug2)
|
|
885
|
+
);
|
|
886
|
+
entries.push({
|
|
887
|
+
loc,
|
|
888
|
+
lastmod: typeof doc["updatedAt"] === "string" ? doc["updatedAt"] : void 0,
|
|
889
|
+
changefreq,
|
|
890
|
+
priority
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
} catch {
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const xml = generateSitemap(entries);
|
|
897
|
+
cache.set("sitemap", xml);
|
|
898
|
+
res.set("Content-Type", "application/xml");
|
|
899
|
+
res.send(xml);
|
|
900
|
+
} catch {
|
|
901
|
+
res.status(500).json({ error: "Failed to generate sitemap" });
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
return { router, clearCache: () => cache.clear() };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// libs/plugins/seo/src/lib/sitemap/sitemap-settings-collection.ts
|
|
908
|
+
function internalOrAdmin2({ req }) {
|
|
909
|
+
if (!req.user)
|
|
910
|
+
return true;
|
|
911
|
+
return req.user.role === "admin";
|
|
912
|
+
}
|
|
913
|
+
var SeoSitemapSettings = defineCollection({
|
|
914
|
+
slug: "seo-sitemap-settings",
|
|
915
|
+
managed: true,
|
|
916
|
+
labels: { singular: "Sitemap Setting", plural: "Sitemap Settings" },
|
|
917
|
+
admin: {
|
|
918
|
+
group: "SEO",
|
|
919
|
+
hidden: true,
|
|
920
|
+
useAsTitle: "collection"
|
|
921
|
+
},
|
|
922
|
+
fields: [
|
|
923
|
+
text("collection", { required: true, label: "Collection Slug" }),
|
|
924
|
+
checkbox("includeInSitemap", {
|
|
925
|
+
label: "Include in Sitemap",
|
|
926
|
+
defaultValue: true
|
|
927
|
+
}),
|
|
928
|
+
number("priority", {
|
|
929
|
+
label: "Priority",
|
|
930
|
+
min: 0,
|
|
931
|
+
max: 1,
|
|
932
|
+
description: "Sitemap priority (0.0 to 1.0)"
|
|
933
|
+
}),
|
|
934
|
+
select("changeFreq", {
|
|
935
|
+
label: "Change Frequency",
|
|
936
|
+
options: [
|
|
937
|
+
{ label: "Always", value: "always" },
|
|
938
|
+
{ label: "Hourly", value: "hourly" },
|
|
939
|
+
{ label: "Daily", value: "daily" },
|
|
940
|
+
{ label: "Weekly", value: "weekly" },
|
|
941
|
+
{ label: "Monthly", value: "monthly" },
|
|
942
|
+
{ label: "Yearly", value: "yearly" },
|
|
943
|
+
{ label: "Never", value: "never" }
|
|
944
|
+
],
|
|
945
|
+
defaultValue: "weekly"
|
|
946
|
+
})
|
|
947
|
+
],
|
|
948
|
+
access: {
|
|
949
|
+
read: internalOrAdmin2,
|
|
950
|
+
create: internalOrAdmin2,
|
|
951
|
+
update: internalOrAdmin2,
|
|
952
|
+
delete: hasRole("admin")
|
|
953
|
+
}
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
// libs/plugins/seo/src/lib/sitemap/sitemap-settings-handler.ts
|
|
957
|
+
var import_express2 = require("express");
|
|
958
|
+
var VALID_CHANGE_FREQS = /* @__PURE__ */ new Set([
|
|
959
|
+
"always",
|
|
960
|
+
"hourly",
|
|
961
|
+
"daily",
|
|
962
|
+
"weekly",
|
|
963
|
+
"monthly",
|
|
964
|
+
"yearly",
|
|
965
|
+
"never"
|
|
966
|
+
]);
|
|
967
|
+
function isAdminUser(req) {
|
|
968
|
+
if (!("user" in req))
|
|
969
|
+
return false;
|
|
970
|
+
const user = req["user"];
|
|
971
|
+
return user != null && typeof user === "object" && "role" in user && user["role"] === "admin";
|
|
972
|
+
}
|
|
973
|
+
function createSitemapSettingsRouter(options) {
|
|
974
|
+
const { getApi, seoCollections, onSettingsChanged } = options;
|
|
975
|
+
const router = (0, import_express2.Router)();
|
|
976
|
+
router.get("/sitemap-settings", async (req, res) => {
|
|
977
|
+
if (!isAdminUser(req)) {
|
|
978
|
+
res.status(401).json({ error: "Admin access required" });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
const api = getApi();
|
|
982
|
+
if (!api) {
|
|
983
|
+
res.status(503).json({ error: "API not ready" });
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
try {
|
|
987
|
+
const settingsApi = api.collection("seo-sitemap-settings");
|
|
988
|
+
if (!settingsApi.find) {
|
|
989
|
+
res.status(503).json({ error: "Collection not available" });
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
const result = await settingsApi.find({ limit: 0 });
|
|
993
|
+
const settingsMap = /* @__PURE__ */ new Map();
|
|
994
|
+
for (const doc of result.docs) {
|
|
995
|
+
if (typeof doc["collection"] === "string") {
|
|
996
|
+
settingsMap.set(doc["collection"], doc);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const settings = seoCollections.map((slug2) => {
|
|
1000
|
+
const saved = settingsMap.get(slug2);
|
|
1001
|
+
return {
|
|
1002
|
+
collection: slug2,
|
|
1003
|
+
includeInSitemap: saved ? saved["includeInSitemap"] !== false : true,
|
|
1004
|
+
priority: saved && saved["priority"] != null ? Number(saved["priority"]) : null,
|
|
1005
|
+
changeFreq: saved ? saved["changeFreq"] ?? null : null,
|
|
1006
|
+
id: saved ? saved["id"] : null
|
|
1007
|
+
};
|
|
1008
|
+
});
|
|
1009
|
+
res.json({ settings });
|
|
1010
|
+
} catch {
|
|
1011
|
+
res.status(500).json({ error: "Failed to fetch sitemap settings" });
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
router.put("/sitemap-settings/:collection", async (req, res) => {
|
|
1015
|
+
if (!isAdminUser(req)) {
|
|
1016
|
+
res.status(401).json({ error: "Admin access required" });
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
const collectionSlug = req.params["collection"];
|
|
1020
|
+
if (!collectionSlug || !seoCollections.includes(collectionSlug)) {
|
|
1021
|
+
res.status(400).json({ error: "Invalid collection slug" });
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const api = getApi();
|
|
1025
|
+
if (!api) {
|
|
1026
|
+
res.status(503).json({ error: "API not ready" });
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
try {
|
|
1030
|
+
const settingsApi = api.collection("seo-sitemap-settings");
|
|
1031
|
+
if (!settingsApi.find || !settingsApi.create || !settingsApi.update) {
|
|
1032
|
+
res.status(503).json({ error: "Collection not available" });
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const body = req.body;
|
|
1036
|
+
const data = {
|
|
1037
|
+
collection: collectionSlug
|
|
1038
|
+
};
|
|
1039
|
+
if ("includeInSitemap" in body) {
|
|
1040
|
+
data["includeInSitemap"] = body["includeInSitemap"] === true;
|
|
1041
|
+
}
|
|
1042
|
+
if ("priority" in body && body["priority"] != null) {
|
|
1043
|
+
data["priority"] = Math.max(0, Math.min(1, Number(body["priority"])));
|
|
1044
|
+
}
|
|
1045
|
+
if ("changeFreq" in body) {
|
|
1046
|
+
const freq = String(body["changeFreq"]);
|
|
1047
|
+
if (!VALID_CHANGE_FREQS.has(freq)) {
|
|
1048
|
+
res.status(400).json({ error: "Invalid changeFreq value" });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
data["changeFreq"] = freq;
|
|
1052
|
+
}
|
|
1053
|
+
const existing = await settingsApi.find({
|
|
1054
|
+
where: { collection: { equals: collectionSlug } },
|
|
1055
|
+
limit: 1
|
|
1056
|
+
});
|
|
1057
|
+
let result;
|
|
1058
|
+
if (existing.docs.length > 0 && typeof existing.docs[0]["id"] === "string") {
|
|
1059
|
+
result = await settingsApi.update(existing.docs[0]["id"], data);
|
|
1060
|
+
} else {
|
|
1061
|
+
result = await settingsApi.create(data);
|
|
1062
|
+
}
|
|
1063
|
+
if (onSettingsChanged) {
|
|
1064
|
+
onSettingsChanged();
|
|
1065
|
+
}
|
|
1066
|
+
res.json(result);
|
|
1067
|
+
} catch {
|
|
1068
|
+
res.status(500).json({ error: "Failed to update sitemap settings" });
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
return router;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// libs/plugins/seo/src/lib/robots/robots-handler.ts
|
|
1075
|
+
var import_express3 = require("express");
|
|
1076
|
+
|
|
1077
|
+
// libs/plugins/seo/src/lib/robots/robots-txt-generator.ts
|
|
1078
|
+
function sanitizeLine(str) {
|
|
1079
|
+
return str.replace(/[\r\n]/g, "");
|
|
1080
|
+
}
|
|
1081
|
+
function generateRobotsTxt(siteUrl, config) {
|
|
1082
|
+
const lines = [];
|
|
1083
|
+
if (config.rules && config.rules.length > 0) {
|
|
1084
|
+
for (const rule of config.rules) {
|
|
1085
|
+
lines.push(`User-agent: ${sanitizeLine(rule.userAgent)}`);
|
|
1086
|
+
if (rule.allow) {
|
|
1087
|
+
for (const path of rule.allow) {
|
|
1088
|
+
lines.push(`Allow: ${sanitizeLine(path)}`);
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
if (rule.disallow) {
|
|
1092
|
+
for (const path of rule.disallow) {
|
|
1093
|
+
lines.push(`Disallow: ${sanitizeLine(path)}`);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (config.crawlDelay != null) {
|
|
1097
|
+
lines.push(`Crawl-delay: ${config.crawlDelay}`);
|
|
1098
|
+
}
|
|
1099
|
+
lines.push("");
|
|
1100
|
+
}
|
|
1101
|
+
} else {
|
|
1102
|
+
lines.push("User-agent: *");
|
|
1103
|
+
lines.push("Allow: /");
|
|
1104
|
+
if (config.crawlDelay != null) {
|
|
1105
|
+
lines.push(`Crawl-delay: ${config.crawlDelay}`);
|
|
1106
|
+
}
|
|
1107
|
+
lines.push("");
|
|
1108
|
+
}
|
|
1109
|
+
if (siteUrl) {
|
|
1110
|
+
lines.push(`Sitemap: ${siteUrl}/sitemap.xml`);
|
|
1111
|
+
}
|
|
1112
|
+
if (config.additionalSitemaps) {
|
|
1113
|
+
for (const sitemap of config.additionalSitemaps) {
|
|
1114
|
+
lines.push(`Sitemap: ${sanitizeLine(sitemap)}`);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
return lines.join("\n");
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
// libs/plugins/seo/src/lib/robots/robots-handler.ts
|
|
1121
|
+
var CACHE_KEY = "robots-txt";
|
|
1122
|
+
async function readSettingsFromDb(api) {
|
|
1123
|
+
try {
|
|
1124
|
+
const settingsApi = api.collection("seo-settings");
|
|
1125
|
+
if (!settingsApi.find)
|
|
1126
|
+
return null;
|
|
1127
|
+
const result = await settingsApi.find({ limit: 1 });
|
|
1128
|
+
if (result.docs.length === 0)
|
|
1129
|
+
return null;
|
|
1130
|
+
const doc = result.docs[0];
|
|
1131
|
+
return {
|
|
1132
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- dynamic DB document shape
|
|
1133
|
+
rules: doc["robotsRules"] ?? void 0,
|
|
1134
|
+
crawlDelay: doc["robotsCrawlDelay"] != null ? Number(doc["robotsCrawlDelay"]) : void 0,
|
|
1135
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- dynamic DB document shape
|
|
1136
|
+
additionalSitemaps: doc["robotsAdditionalSitemaps"] ?? void 0
|
|
1137
|
+
};
|
|
1138
|
+
} catch {
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
function createRobotsRouter(options) {
|
|
1143
|
+
const { siteUrl, config, getApi } = options;
|
|
1144
|
+
const cache = new SitemapCache(3e5);
|
|
1145
|
+
const router = (0, import_express3.Router)();
|
|
1146
|
+
router.get("/robots.txt", async (req, res) => {
|
|
1147
|
+
const cached = cache.get(CACHE_KEY);
|
|
1148
|
+
if (cached) {
|
|
1149
|
+
res.set("Content-Type", "text/plain");
|
|
1150
|
+
res.send(cached);
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
const host = req.get("host");
|
|
1154
|
+
const effectiveSiteUrl = host ? `${req.protocol}://${host}` : siteUrl || "http://localhost";
|
|
1155
|
+
let effectiveConfig = config;
|
|
1156
|
+
if (getApi) {
|
|
1157
|
+
const api = getApi();
|
|
1158
|
+
if (api) {
|
|
1159
|
+
const dbConfig = await readSettingsFromDb(api);
|
|
1160
|
+
if (dbConfig) {
|
|
1161
|
+
effectiveConfig = dbConfig;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const content = generateRobotsTxt(effectiveSiteUrl, effectiveConfig);
|
|
1166
|
+
cache.set(CACHE_KEY, content);
|
|
1167
|
+
res.set("Content-Type", "text/plain");
|
|
1168
|
+
res.send(content);
|
|
1169
|
+
});
|
|
1170
|
+
return {
|
|
1171
|
+
router,
|
|
1172
|
+
clearCache: () => {
|
|
1173
|
+
cache.clear();
|
|
1174
|
+
}
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// libs/plugins/seo/src/lib/meta/meta-handler.ts
|
|
1179
|
+
var import_express4 = require("express");
|
|
1180
|
+
|
|
1181
|
+
// libs/plugins/seo/src/lib/meta/meta-builder.ts
|
|
1182
|
+
function extractOgImageUrl(ogImage) {
|
|
1183
|
+
if (typeof ogImage === "string")
|
|
1184
|
+
return ogImage;
|
|
1185
|
+
if (typeof ogImage === "object" && ogImage !== null && "url" in ogImage) {
|
|
1186
|
+
const url = ogImage["url"];
|
|
1187
|
+
return typeof url === "string" ? url : void 0;
|
|
1188
|
+
}
|
|
1189
|
+
return void 0;
|
|
1190
|
+
}
|
|
1191
|
+
function buildMetaTags(doc, seo, _siteUrl) {
|
|
1192
|
+
const title = seo.metaTitle ?? (typeof doc["title"] === "string" ? doc["title"] : "");
|
|
1193
|
+
const meta = [];
|
|
1194
|
+
const link = [];
|
|
1195
|
+
const script = [];
|
|
1196
|
+
if (seo.metaDescription) {
|
|
1197
|
+
meta.push({ name: "description", content: seo.metaDescription });
|
|
1198
|
+
}
|
|
1199
|
+
const robotsParts = [];
|
|
1200
|
+
if (seo.noIndex)
|
|
1201
|
+
robotsParts.push("noindex");
|
|
1202
|
+
if (seo.noFollow)
|
|
1203
|
+
robotsParts.push("nofollow");
|
|
1204
|
+
if (robotsParts.length > 0) {
|
|
1205
|
+
meta.push({ name: "robots", content: robotsParts.join(", ") });
|
|
1206
|
+
}
|
|
1207
|
+
const ogTitle = seo.ogTitle ?? seo.metaTitle ?? (typeof doc["title"] === "string" ? doc["title"] : "");
|
|
1208
|
+
if (ogTitle) {
|
|
1209
|
+
meta.push({ property: "og:title", content: ogTitle });
|
|
1210
|
+
}
|
|
1211
|
+
const ogDescription = seo.ogDescription ?? seo.metaDescription;
|
|
1212
|
+
if (ogDescription) {
|
|
1213
|
+
meta.push({ property: "og:description", content: ogDescription });
|
|
1214
|
+
}
|
|
1215
|
+
if (seo.ogImage) {
|
|
1216
|
+
const imageUrl = extractOgImageUrl(seo.ogImage);
|
|
1217
|
+
if (imageUrl) {
|
|
1218
|
+
meta.push({ property: "og:image", content: imageUrl });
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
if (seo.ogType) {
|
|
1222
|
+
meta.push({ property: "og:type", content: seo.ogType });
|
|
1223
|
+
}
|
|
1224
|
+
if (seo.twitterCard) {
|
|
1225
|
+
meta.push({ name: "twitter:card", content: seo.twitterCard });
|
|
1226
|
+
}
|
|
1227
|
+
if (seo.canonicalUrl) {
|
|
1228
|
+
link.push({ rel: "canonical", href: seo.canonicalUrl });
|
|
1229
|
+
}
|
|
1230
|
+
if (seo.structuredData) {
|
|
1231
|
+
script.push({
|
|
1232
|
+
type: "application/ld+json",
|
|
1233
|
+
innerHTML: JSON.stringify(seo.structuredData).replace(/</g, "\\u003c")
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
return { title, meta, link, script };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// libs/plugins/seo/src/lib/meta/meta-handler.ts
|
|
1240
|
+
function isAdminUser2(req) {
|
|
1241
|
+
if (!("user" in req))
|
|
1242
|
+
return false;
|
|
1243
|
+
const user = req["user"];
|
|
1244
|
+
return user != null && typeof user === "object" && "role" in user && user["role"] === "admin";
|
|
1245
|
+
}
|
|
1246
|
+
function createMetaRouter(options) {
|
|
1247
|
+
const { getApi, siteUrl, seoCollections } = options;
|
|
1248
|
+
const allowedCollections = new Set(seoCollections);
|
|
1249
|
+
const router = (0, import_express4.Router)();
|
|
1250
|
+
router.get("/meta/:collection/:id", async (req, res) => {
|
|
1251
|
+
if (!isAdminUser2(req)) {
|
|
1252
|
+
res.status(401).json({ error: "Admin access required" });
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const api = getApi();
|
|
1256
|
+
if (!api) {
|
|
1257
|
+
res.status(503).json({ error: "API not ready" });
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
const { collection, id } = req.params;
|
|
1261
|
+
if (!allowedCollections.has(collection)) {
|
|
1262
|
+
res.status(404).json({ error: "Collection not SEO-enabled" });
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
try {
|
|
1266
|
+
const collectionApi = api.collection(collection);
|
|
1267
|
+
if (!collectionApi.findById) {
|
|
1268
|
+
res.status(404).json({ error: "Collection not found" });
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const doc = await collectionApi.findById(id, { depth: 1 });
|
|
1272
|
+
if (!doc) {
|
|
1273
|
+
res.status(404).json({ error: "Document not found" });
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const seoData = extractSeoFieldData(doc);
|
|
1277
|
+
const metaTags = buildMetaTags(doc, seoData, siteUrl);
|
|
1278
|
+
res.json(metaTags);
|
|
1279
|
+
} catch {
|
|
1280
|
+
res.status(500).json({ error: "Failed to build meta tags" });
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
return router;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
// libs/plugins/seo/src/lib/dashboard/seo-analysis-handler.ts
|
|
1287
|
+
var import_express5 = require("express");
|
|
1288
|
+
function isAdminUser3(req) {
|
|
1289
|
+
if (!("user" in req))
|
|
1290
|
+
return false;
|
|
1291
|
+
const user = req["user"];
|
|
1292
|
+
return user != null && typeof user === "object" && "role" in user && user["role"] === "admin";
|
|
1293
|
+
}
|
|
1294
|
+
function createDashboardRouter(options) {
|
|
1295
|
+
const { getApi } = options;
|
|
1296
|
+
const router = (0, import_express5.Router)();
|
|
1297
|
+
router.get("/analyses", async (req, res) => {
|
|
1298
|
+
if (!isAdminUser3(req)) {
|
|
1299
|
+
res.status(401).json({ error: "Admin access required" });
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const api = getApi();
|
|
1303
|
+
if (!api) {
|
|
1304
|
+
res.status(503).json({ error: "API not ready" });
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
try {
|
|
1308
|
+
const analysisApi = api.collection("seo-analysis");
|
|
1309
|
+
if (!analysisApi.find) {
|
|
1310
|
+
res.status(503).json({ error: "Collection not available" });
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
const limit = Math.min(Number(req.query["limit"]) || 500, 1e3);
|
|
1314
|
+
const sort = String(req.query["sort"] || "-analyzedAt");
|
|
1315
|
+
const result = await analysisApi.find({ limit, sort });
|
|
1316
|
+
res.json(result);
|
|
1317
|
+
} catch {
|
|
1318
|
+
res.status(500).json({ error: "Failed to fetch analyses" });
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
return router;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// libs/plugins/seo/src/lib/settings/seo-settings-collection.ts
|
|
1325
|
+
function internalOrAdmin3({ req }) {
|
|
1326
|
+
if (!req.user)
|
|
1327
|
+
return true;
|
|
1328
|
+
return req.user.role === "admin";
|
|
1329
|
+
}
|
|
1330
|
+
var SeoSettings = defineCollection({
|
|
1331
|
+
slug: "seo-settings",
|
|
1332
|
+
managed: true,
|
|
1333
|
+
labels: { singular: "SEO Setting", plural: "SEO Settings" },
|
|
1334
|
+
admin: {
|
|
1335
|
+
group: "SEO",
|
|
1336
|
+
hidden: true
|
|
1337
|
+
},
|
|
1338
|
+
fields: [
|
|
1339
|
+
json("robotsRules", {
|
|
1340
|
+
label: "Robots Rules",
|
|
1341
|
+
description: "Array of { userAgent, allow, disallow } rules"
|
|
1342
|
+
}),
|
|
1343
|
+
number("robotsCrawlDelay", {
|
|
1344
|
+
label: "Crawl Delay",
|
|
1345
|
+
min: 0,
|
|
1346
|
+
description: "Crawl delay in seconds (optional)"
|
|
1347
|
+
}),
|
|
1348
|
+
json("robotsAdditionalSitemaps", {
|
|
1349
|
+
label: "Additional Sitemaps",
|
|
1350
|
+
description: "Array of additional sitemap URLs to include"
|
|
1351
|
+
})
|
|
1352
|
+
],
|
|
1353
|
+
access: {
|
|
1354
|
+
read: internalOrAdmin3,
|
|
1355
|
+
create: internalOrAdmin3,
|
|
1356
|
+
update: internalOrAdmin3,
|
|
1357
|
+
delete: hasRole("admin")
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// libs/plugins/seo/src/lib/settings/seo-settings-handler.ts
|
|
1362
|
+
var import_express6 = require("express");
|
|
1363
|
+
var DEFAULT_RULES = [{ userAgent: "*", allow: ["/"], disallow: [] }];
|
|
1364
|
+
function isAdminUser4(req) {
|
|
1365
|
+
if (!("user" in req))
|
|
1366
|
+
return false;
|
|
1367
|
+
const user = req["user"];
|
|
1368
|
+
return user != null && typeof user === "object" && "role" in user && user["role"] === "admin";
|
|
1369
|
+
}
|
|
1370
|
+
function createSeoSettingsRouter(options) {
|
|
1371
|
+
const { getApi, defaultRobotsConfig, onSettingsChanged } = options;
|
|
1372
|
+
const router = (0, import_express6.Router)();
|
|
1373
|
+
router.get("/seo-settings", async (req, res) => {
|
|
1374
|
+
if (!isAdminUser4(req)) {
|
|
1375
|
+
res.status(401).json({ error: "Admin access required" });
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
const api = getApi();
|
|
1379
|
+
if (!api) {
|
|
1380
|
+
res.status(503).json({ error: "API not ready" });
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
try {
|
|
1384
|
+
const settingsApi = api.collection("seo-settings");
|
|
1385
|
+
if (!settingsApi.find) {
|
|
1386
|
+
res.status(503).json({ error: "Collection not available" });
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
const result = await settingsApi.find({ limit: 1 });
|
|
1390
|
+
if (result.docs.length > 0) {
|
|
1391
|
+
res.json(result.docs[0]);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const defaultRules = defaultRobotsConfig?.rules ?? DEFAULT_RULES;
|
|
1395
|
+
res.json({
|
|
1396
|
+
robotsRules: defaultRules,
|
|
1397
|
+
robotsCrawlDelay: defaultRobotsConfig?.crawlDelay ?? null,
|
|
1398
|
+
robotsAdditionalSitemaps: defaultRobotsConfig?.additionalSitemaps ?? []
|
|
1399
|
+
});
|
|
1400
|
+
} catch {
|
|
1401
|
+
res.status(500).json({ error: "Failed to fetch SEO settings" });
|
|
1402
|
+
}
|
|
1403
|
+
});
|
|
1404
|
+
router.put("/seo-settings", async (req, res) => {
|
|
1405
|
+
if (!isAdminUser4(req)) {
|
|
1406
|
+
res.status(401).json({ error: "Admin access required" });
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const api = getApi();
|
|
1410
|
+
if (!api) {
|
|
1411
|
+
res.status(503).json({ error: "API not ready" });
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
try {
|
|
1415
|
+
const settingsApi = api.collection("seo-settings");
|
|
1416
|
+
if (!settingsApi.find || !settingsApi.create || !settingsApi.update) {
|
|
1417
|
+
res.status(503).json({ error: "Collection not available" });
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const body = req.body;
|
|
1421
|
+
const data = {};
|
|
1422
|
+
if ("robotsRules" in body) {
|
|
1423
|
+
if (!Array.isArray(body["robotsRules"])) {
|
|
1424
|
+
res.status(400).json({ error: "robotsRules must be an array" });
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
data["robotsRules"] = body["robotsRules"].map(
|
|
1428
|
+
(rule) => ({
|
|
1429
|
+
userAgent: String(rule["userAgent"] ?? "*").replace(/[\r\n]/g, ""),
|
|
1430
|
+
allow: Array.isArray(rule["allow"]) ? (
|
|
1431
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated as array above
|
|
1432
|
+
rule["allow"].map((p) => String(p).replace(/[\r\n]/g, ""))
|
|
1433
|
+
) : [],
|
|
1434
|
+
disallow: Array.isArray(rule["disallow"]) ? (
|
|
1435
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validated as array above
|
|
1436
|
+
rule["disallow"].map((p) => String(p).replace(/[\r\n]/g, ""))
|
|
1437
|
+
) : []
|
|
1438
|
+
})
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
if ("robotsCrawlDelay" in body) {
|
|
1442
|
+
const delay = body["robotsCrawlDelay"];
|
|
1443
|
+
data["robotsCrawlDelay"] = delay != null ? Math.max(0, Number(delay)) : null;
|
|
1444
|
+
}
|
|
1445
|
+
if ("robotsAdditionalSitemaps" in body) {
|
|
1446
|
+
if (!Array.isArray(body["robotsAdditionalSitemaps"])) {
|
|
1447
|
+
res.status(400).json({ error: "robotsAdditionalSitemaps must be an array" });
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
data["robotsAdditionalSitemaps"] = body["robotsAdditionalSitemaps"].map(
|
|
1451
|
+
(s) => String(s).replace(/[\r\n]/g, "")
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
const existing = await settingsApi.find({ limit: 1 });
|
|
1455
|
+
let result;
|
|
1456
|
+
if (existing.docs.length > 0 && typeof existing.docs[0]["id"] === "string") {
|
|
1457
|
+
result = await settingsApi.update(existing.docs[0]["id"], data);
|
|
1458
|
+
} else {
|
|
1459
|
+
result = await settingsApi.create(data);
|
|
1460
|
+
}
|
|
1461
|
+
if (onSettingsChanged) {
|
|
1462
|
+
onSettingsChanged();
|
|
1463
|
+
}
|
|
1464
|
+
res.json(result);
|
|
1465
|
+
} catch {
|
|
1466
|
+
res.status(500).json({ error: "Failed to update SEO settings" });
|
|
1467
|
+
}
|
|
1468
|
+
});
|
|
1469
|
+
return router;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// libs/plugins/seo/src/lib/seo-plugin.ts
|
|
1473
|
+
function resolveAdminRoutes(dashboardConfig) {
|
|
1474
|
+
if (dashboardConfig === false)
|
|
1475
|
+
return [];
|
|
1476
|
+
const dashboardModule = "./dashboard/seo-dashboard.page";
|
|
1477
|
+
const defaultLoadComponent = () => import(dashboardModule).then((m) => m["SeoDashboardPage"]);
|
|
1478
|
+
const defaultRoute = {
|
|
1479
|
+
path: "seo",
|
|
1480
|
+
label: "SEO",
|
|
1481
|
+
icon: "heroMagnifyingGlass",
|
|
1482
|
+
loadComponent: defaultLoadComponent,
|
|
1483
|
+
group: "SEO"
|
|
1484
|
+
};
|
|
1485
|
+
const sitemapModule = "./sitemap/sitemap-settings.page";
|
|
1486
|
+
const sitemapRoute = {
|
|
1487
|
+
path: "seo/sitemap",
|
|
1488
|
+
label: "Sitemap",
|
|
1489
|
+
icon: "heroMap",
|
|
1490
|
+
loadComponent: () => import(sitemapModule).then((m) => m["SitemapSettingsPage"]),
|
|
1491
|
+
group: "SEO"
|
|
1492
|
+
};
|
|
1493
|
+
const robotsModule = "./robots/robots-settings.page";
|
|
1494
|
+
const robotsRoute = {
|
|
1495
|
+
path: "seo/robots",
|
|
1496
|
+
label: "Robots",
|
|
1497
|
+
icon: "heroDocumentText",
|
|
1498
|
+
loadComponent: () => import(robotsModule).then((m) => m["RobotsSettingsPage"]),
|
|
1499
|
+
group: "SEO"
|
|
1500
|
+
};
|
|
1501
|
+
if (dashboardConfig === void 0 || dashboardConfig === true) {
|
|
1502
|
+
return [defaultRoute, sitemapRoute, robotsRoute];
|
|
1503
|
+
}
|
|
1504
|
+
return [
|
|
1505
|
+
{
|
|
1506
|
+
...defaultRoute,
|
|
1507
|
+
loadComponent: dashboardConfig.loadComponent ?? defaultLoadComponent,
|
|
1508
|
+
group: dashboardConfig.group ?? defaultRoute.group
|
|
1509
|
+
},
|
|
1510
|
+
sitemapRoute,
|
|
1511
|
+
robotsRoute
|
|
1512
|
+
];
|
|
1513
|
+
}
|
|
1514
|
+
function resolveAnalysisConfig(analysis) {
|
|
1515
|
+
if (analysis === false)
|
|
1516
|
+
return null;
|
|
1517
|
+
if (analysis === void 0 || analysis === true)
|
|
1518
|
+
return {};
|
|
1519
|
+
return analysis;
|
|
1520
|
+
}
|
|
1521
|
+
function seoPlugin(config) {
|
|
1522
|
+
const adminRoutes = resolveAdminRoutes(config.adminDashboard);
|
|
1523
|
+
const analysisConfig = resolveAnalysisConfig(config.analysis);
|
|
1524
|
+
const siteUrl = config.siteUrl ?? "";
|
|
1525
|
+
let momentumApi = null;
|
|
1526
|
+
const seoCollectionSlugs = /* @__PURE__ */ new Set();
|
|
1527
|
+
return {
|
|
1528
|
+
name: "seo",
|
|
1529
|
+
adminRoutes,
|
|
1530
|
+
browserImports: {
|
|
1531
|
+
adminRoutes: {
|
|
1532
|
+
path: "@momentumcms/plugins-seo/admin-routes",
|
|
1533
|
+
exportName: "seoAdminRoutes"
|
|
1534
|
+
},
|
|
1535
|
+
modifyCollections: {
|
|
1536
|
+
path: "@momentumcms/plugins-seo/fields",
|
|
1537
|
+
exportName: "injectSeoFields"
|
|
1538
|
+
}
|
|
1539
|
+
},
|
|
1540
|
+
modifyCollections(collections) {
|
|
1541
|
+
if (config.enabled === false)
|
|
1542
|
+
return;
|
|
1543
|
+
injectSeoFields(collections, {
|
|
1544
|
+
collections: config.collections,
|
|
1545
|
+
excludeCollections: config.excludeCollections
|
|
1546
|
+
});
|
|
1547
|
+
},
|
|
1548
|
+
async onInit({ collections, logger, registerMiddleware }) {
|
|
1549
|
+
if (config.enabled === false) {
|
|
1550
|
+
logger.info("SEO plugin disabled");
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
injectSeoFields(collections, {
|
|
1554
|
+
collections: config.collections,
|
|
1555
|
+
excludeCollections: config.excludeCollections
|
|
1556
|
+
});
|
|
1557
|
+
logger.info("SEO fields injected");
|
|
1558
|
+
for (const c of collections) {
|
|
1559
|
+
if (hasSeoField(c)) {
|
|
1560
|
+
seoCollectionSlugs.add(c.slug);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
if (analysisConfig && !collections.some((c) => c.slug === "seo-analysis")) {
|
|
1564
|
+
collections.push(SeoAnalysis);
|
|
1565
|
+
injectSeoAnalysisHooks(collections, analysisConfig, () => momentumApi);
|
|
1566
|
+
logger.info("SEO analysis hooks injected");
|
|
1567
|
+
}
|
|
1568
|
+
if (config.sitemap !== false) {
|
|
1569
|
+
if (!collections.some((c) => c.slug === "seo-sitemap-settings")) {
|
|
1570
|
+
collections.push(SeoSitemapSettings);
|
|
1571
|
+
}
|
|
1572
|
+
const sitemapConfig = typeof config.sitemap === "object" ? config.sitemap : {};
|
|
1573
|
+
const { router: sitemapRouter, clearCache } = createSitemapRouter({
|
|
1574
|
+
getApi: () => momentumApi,
|
|
1575
|
+
siteUrl,
|
|
1576
|
+
config: sitemapConfig,
|
|
1577
|
+
seoCollections: [...seoCollectionSlugs]
|
|
1578
|
+
});
|
|
1579
|
+
registerMiddleware({
|
|
1580
|
+
path: "/",
|
|
1581
|
+
handler: sitemapRouter,
|
|
1582
|
+
position: "root"
|
|
1583
|
+
});
|
|
1584
|
+
logger.info("Sitemap endpoint registered at /sitemap.xml");
|
|
1585
|
+
const settingsRouter = createSitemapSettingsRouter({
|
|
1586
|
+
getApi: () => momentumApi,
|
|
1587
|
+
seoCollections: [...seoCollectionSlugs],
|
|
1588
|
+
onSettingsChanged: clearCache
|
|
1589
|
+
});
|
|
1590
|
+
registerMiddleware({
|
|
1591
|
+
path: "/seo",
|
|
1592
|
+
handler: settingsRouter,
|
|
1593
|
+
position: "before-api"
|
|
1594
|
+
});
|
|
1595
|
+
logger.info("Sitemap settings endpoint registered");
|
|
1596
|
+
}
|
|
1597
|
+
if (config.robots !== false) {
|
|
1598
|
+
const robotsConfig = typeof config.robots === "object" ? config.robots : {};
|
|
1599
|
+
if (!collections.some((c) => c.slug === "seo-settings")) {
|
|
1600
|
+
collections.push(SeoSettings);
|
|
1601
|
+
}
|
|
1602
|
+
const { router: robotsRouter, clearCache: clearRobotsCache } = createRobotsRouter({
|
|
1603
|
+
siteUrl,
|
|
1604
|
+
config: robotsConfig,
|
|
1605
|
+
getApi: () => momentumApi
|
|
1606
|
+
});
|
|
1607
|
+
registerMiddleware({
|
|
1608
|
+
path: "/",
|
|
1609
|
+
handler: robotsRouter,
|
|
1610
|
+
position: "root"
|
|
1611
|
+
});
|
|
1612
|
+
logger.info("Robots.txt endpoint registered at /robots.txt");
|
|
1613
|
+
const seoSettingsRouter = createSeoSettingsRouter({
|
|
1614
|
+
getApi: () => momentumApi,
|
|
1615
|
+
defaultRobotsConfig: robotsConfig,
|
|
1616
|
+
onSettingsChanged: clearRobotsCache
|
|
1617
|
+
});
|
|
1618
|
+
registerMiddleware({
|
|
1619
|
+
path: "/seo",
|
|
1620
|
+
handler: seoSettingsRouter,
|
|
1621
|
+
position: "before-api"
|
|
1622
|
+
});
|
|
1623
|
+
logger.info("SEO settings endpoint registered");
|
|
1624
|
+
}
|
|
1625
|
+
if (config.metaApi !== false) {
|
|
1626
|
+
const metaRouter = createMetaRouter({
|
|
1627
|
+
getApi: () => momentumApi,
|
|
1628
|
+
siteUrl,
|
|
1629
|
+
seoCollections: [...seoCollectionSlugs]
|
|
1630
|
+
});
|
|
1631
|
+
registerMiddleware({
|
|
1632
|
+
path: "/seo",
|
|
1633
|
+
handler: metaRouter,
|
|
1634
|
+
position: "before-api"
|
|
1635
|
+
});
|
|
1636
|
+
logger.info("Meta tag API endpoint registered");
|
|
1637
|
+
}
|
|
1638
|
+
const dashboardRouter = createDashboardRouter({ getApi: () => momentumApi });
|
|
1639
|
+
registerMiddleware({
|
|
1640
|
+
path: "/seo",
|
|
1641
|
+
handler: dashboardRouter,
|
|
1642
|
+
position: "before-api"
|
|
1643
|
+
});
|
|
1644
|
+
logger.info("SEO dashboard API endpoint registered");
|
|
1645
|
+
if (adminRoutes.length > 0) {
|
|
1646
|
+
logger.info("SEO admin dashboard route declared");
|
|
1647
|
+
}
|
|
1648
|
+
logger.info("SEO plugin initialized");
|
|
1649
|
+
},
|
|
1650
|
+
async onReady({ logger, api }) {
|
|
1651
|
+
if (config.enabled === false)
|
|
1652
|
+
return;
|
|
1653
|
+
momentumApi = api;
|
|
1654
|
+
logger.info("SEO plugin ready");
|
|
1655
|
+
},
|
|
1656
|
+
async onShutdown({ logger }) {
|
|
1657
|
+
logger.info("SEO plugin shut down");
|
|
1658
|
+
}
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1662
|
+
0 && (module.exports = {
|
|
1663
|
+
injectSeoFields,
|
|
1664
|
+
seoPlugin
|
|
1665
|
+
});
|