@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
|
@@ -0,0 +1,312 @@
|
|
|
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/lib/seo-field-injector.ts
|
|
21
|
+
var seo_field_injector_exports = {};
|
|
22
|
+
__export(seo_field_injector_exports, {
|
|
23
|
+
injectSeoFields: () => injectSeoFields
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(seo_field_injector_exports);
|
|
26
|
+
|
|
27
|
+
// libs/core/src/lib/collections/define-collection.ts
|
|
28
|
+
function defineCollection(config) {
|
|
29
|
+
const collection = {
|
|
30
|
+
timestamps: true,
|
|
31
|
+
// Enable timestamps by default
|
|
32
|
+
...config
|
|
33
|
+
};
|
|
34
|
+
if (!collection.slug) {
|
|
35
|
+
throw new Error("Collection must have a slug");
|
|
36
|
+
}
|
|
37
|
+
if (!collection.fields || collection.fields.length === 0) {
|
|
38
|
+
throw new Error(`Collection "${collection.slug}" must have at least one field`);
|
|
39
|
+
}
|
|
40
|
+
if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return collection;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// libs/core/src/lib/fields/field-builders.ts
|
|
49
|
+
function text(name, options = {}) {
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
type: "text",
|
|
53
|
+
...options
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function textarea(name, options = {}) {
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
type: "textarea",
|
|
60
|
+
...options
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function number(name, options = {}) {
|
|
64
|
+
return {
|
|
65
|
+
name,
|
|
66
|
+
type: "number",
|
|
67
|
+
...options
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function checkbox(name, options = {}) {
|
|
71
|
+
return {
|
|
72
|
+
name,
|
|
73
|
+
type: "checkbox",
|
|
74
|
+
...options,
|
|
75
|
+
defaultValue: options.defaultValue ?? false
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function select(name, options) {
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
type: "select",
|
|
82
|
+
...options
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function upload(name, options = {}) {
|
|
86
|
+
return {
|
|
87
|
+
name,
|
|
88
|
+
type: "upload",
|
|
89
|
+
relationTo: options.relationTo ?? "media",
|
|
90
|
+
...options
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function json(name, options = {}) {
|
|
94
|
+
return {
|
|
95
|
+
name,
|
|
96
|
+
type: "json",
|
|
97
|
+
...options
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function tabs(name, options) {
|
|
101
|
+
return {
|
|
102
|
+
name,
|
|
103
|
+
type: "tabs",
|
|
104
|
+
...options
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// libs/core/src/lib/collections/media.collection.ts
|
|
109
|
+
var MediaCollection = defineCollection({
|
|
110
|
+
slug: "media",
|
|
111
|
+
labels: {
|
|
112
|
+
singular: "Media",
|
|
113
|
+
plural: "Media"
|
|
114
|
+
},
|
|
115
|
+
upload: {
|
|
116
|
+
mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
|
|
117
|
+
},
|
|
118
|
+
admin: {
|
|
119
|
+
useAsTitle: "filename",
|
|
120
|
+
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
121
|
+
},
|
|
122
|
+
fields: [
|
|
123
|
+
text("filename", {
|
|
124
|
+
required: true,
|
|
125
|
+
label: "Filename",
|
|
126
|
+
description: "Original filename of the uploaded file"
|
|
127
|
+
}),
|
|
128
|
+
text("mimeType", {
|
|
129
|
+
required: true,
|
|
130
|
+
label: "MIME Type",
|
|
131
|
+
description: "File MIME type (e.g., image/jpeg, application/pdf)"
|
|
132
|
+
}),
|
|
133
|
+
number("filesize", {
|
|
134
|
+
label: "File Size",
|
|
135
|
+
description: "File size in bytes"
|
|
136
|
+
}),
|
|
137
|
+
text("path", {
|
|
138
|
+
label: "Storage Path",
|
|
139
|
+
description: "Path/key where the file is stored",
|
|
140
|
+
admin: {
|
|
141
|
+
hidden: true
|
|
142
|
+
}
|
|
143
|
+
}),
|
|
144
|
+
text("url", {
|
|
145
|
+
label: "URL",
|
|
146
|
+
description: "Public URL to access the file"
|
|
147
|
+
}),
|
|
148
|
+
text("alt", {
|
|
149
|
+
label: "Alt Text",
|
|
150
|
+
description: "Alternative text for accessibility"
|
|
151
|
+
}),
|
|
152
|
+
number("width", {
|
|
153
|
+
label: "Width",
|
|
154
|
+
description: "Image width in pixels (for images only)"
|
|
155
|
+
}),
|
|
156
|
+
number("height", {
|
|
157
|
+
label: "Height",
|
|
158
|
+
description: "Image height in pixels (for images only)"
|
|
159
|
+
}),
|
|
160
|
+
json("focalPoint", {
|
|
161
|
+
label: "Focal Point",
|
|
162
|
+
description: "Focal point coordinates for image cropping",
|
|
163
|
+
admin: {
|
|
164
|
+
hidden: true
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
],
|
|
168
|
+
access: {
|
|
169
|
+
// Media is readable by anyone by default
|
|
170
|
+
read: () => true,
|
|
171
|
+
// Only authenticated users can create/update/delete
|
|
172
|
+
create: ({ req }) => !!req?.user,
|
|
173
|
+
update: ({ req }) => !!req?.user,
|
|
174
|
+
delete: ({ req }) => !!req?.user
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// libs/plugins/seo/src/lib/seo-fields.ts
|
|
179
|
+
function getSeoFields() {
|
|
180
|
+
return [
|
|
181
|
+
text("metaTitle", {
|
|
182
|
+
label: "Meta Title",
|
|
183
|
+
maxLength: 70,
|
|
184
|
+
description: "Title tag for search engines (50-60 chars recommended)"
|
|
185
|
+
}),
|
|
186
|
+
textarea("metaDescription", {
|
|
187
|
+
label: "Meta Description",
|
|
188
|
+
maxLength: 160,
|
|
189
|
+
rows: 3,
|
|
190
|
+
description: "Description for search results (120-155 chars recommended)"
|
|
191
|
+
}),
|
|
192
|
+
text("canonicalUrl", {
|
|
193
|
+
label: "Canonical URL",
|
|
194
|
+
description: "Override the canonical URL for this page"
|
|
195
|
+
}),
|
|
196
|
+
text("focusKeyword", {
|
|
197
|
+
label: "Focus Keyword",
|
|
198
|
+
description: "Primary keyword to optimize this content for"
|
|
199
|
+
}),
|
|
200
|
+
text("ogTitle", {
|
|
201
|
+
label: "OG Title",
|
|
202
|
+
description: "Open Graph title (falls back to Meta Title)"
|
|
203
|
+
}),
|
|
204
|
+
textarea("ogDescription", {
|
|
205
|
+
label: "OG Description",
|
|
206
|
+
rows: 2,
|
|
207
|
+
description: "Open Graph description (falls back to Meta Description)"
|
|
208
|
+
}),
|
|
209
|
+
upload("ogImage", {
|
|
210
|
+
label: "OG Image",
|
|
211
|
+
relationTo: "media",
|
|
212
|
+
mimeTypes: ["image/*"],
|
|
213
|
+
description: "Recommended size: 1200x630px"
|
|
214
|
+
}),
|
|
215
|
+
select("ogType", {
|
|
216
|
+
label: "OG Type",
|
|
217
|
+
options: [
|
|
218
|
+
{ label: "Website", value: "website" },
|
|
219
|
+
{ label: "Article", value: "article" },
|
|
220
|
+
{ label: "Product", value: "product" },
|
|
221
|
+
{ label: "Profile", value: "profile" }
|
|
222
|
+
],
|
|
223
|
+
defaultValue: "website"
|
|
224
|
+
}),
|
|
225
|
+
select("twitterCard", {
|
|
226
|
+
label: "Twitter Card",
|
|
227
|
+
options: [
|
|
228
|
+
{ label: "Summary", value: "summary" },
|
|
229
|
+
{ label: "Summary Large Image", value: "summary_large_image" },
|
|
230
|
+
{ label: "Player", value: "player" },
|
|
231
|
+
{ label: "App", value: "app" }
|
|
232
|
+
],
|
|
233
|
+
defaultValue: "summary_large_image"
|
|
234
|
+
}),
|
|
235
|
+
checkbox("noIndex", {
|
|
236
|
+
label: "No Index",
|
|
237
|
+
description: "Tell search engines not to index this page"
|
|
238
|
+
}),
|
|
239
|
+
checkbox("noFollow", {
|
|
240
|
+
label: "No Follow",
|
|
241
|
+
description: "Tell search engines not to follow links on this page"
|
|
242
|
+
}),
|
|
243
|
+
checkbox("excludeFromSitemap", {
|
|
244
|
+
label: "Exclude from Sitemap",
|
|
245
|
+
description: "Exclude this page from the XML sitemap without affecting search engine indexing"
|
|
246
|
+
}),
|
|
247
|
+
json("structuredData", {
|
|
248
|
+
label: "Structured Data (JSON-LD)",
|
|
249
|
+
description: "Custom JSON-LD structured data for this page"
|
|
250
|
+
})
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
function createSeoTabConfig() {
|
|
254
|
+
return {
|
|
255
|
+
name: "seo",
|
|
256
|
+
label: "SEO",
|
|
257
|
+
description: "Search engine optimization settings",
|
|
258
|
+
fields: getSeoFields()
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// libs/plugins/seo/src/lib/seo-utils.ts
|
|
263
|
+
function hasSeoField(collection) {
|
|
264
|
+
for (const field of collection.fields) {
|
|
265
|
+
if (field.name === "seo" && field.type === "group")
|
|
266
|
+
return true;
|
|
267
|
+
if (field.type === "tabs") {
|
|
268
|
+
const tabsField = field;
|
|
269
|
+
if (tabsField.tabs.some((t) => t.name === "seo"))
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// libs/plugins/seo/src/lib/seo-field-injector.ts
|
|
277
|
+
function shouldInject(collection, options) {
|
|
278
|
+
if (collection.managed)
|
|
279
|
+
return false;
|
|
280
|
+
if (collection.slug.startsWith("seo-"))
|
|
281
|
+
return false;
|
|
282
|
+
if (options.collections === "*") {
|
|
283
|
+
return !(options.excludeCollections ?? []).includes(collection.slug);
|
|
284
|
+
}
|
|
285
|
+
return options.collections.includes(collection.slug);
|
|
286
|
+
}
|
|
287
|
+
function findTopLevelTabs(collection) {
|
|
288
|
+
return collection.fields.find((f) => f.type === "tabs");
|
|
289
|
+
}
|
|
290
|
+
function injectSeoFields(collections, options) {
|
|
291
|
+
const seoTab = createSeoTabConfig();
|
|
292
|
+
for (const collection of collections) {
|
|
293
|
+
if (!shouldInject(collection, options))
|
|
294
|
+
continue;
|
|
295
|
+
if (hasSeoField(collection))
|
|
296
|
+
continue;
|
|
297
|
+
const existingTabs = findTopLevelTabs(collection);
|
|
298
|
+
if (existingTabs) {
|
|
299
|
+
existingTabs.tabs.push(seoTab);
|
|
300
|
+
} else {
|
|
301
|
+
const originalFields = [...collection.fields];
|
|
302
|
+
const tabsField = tabs("seoTabs", {
|
|
303
|
+
tabs: [{ label: "Content", fields: originalFields }, seoTab]
|
|
304
|
+
});
|
|
305
|
+
collection.fields = [tabsField];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
310
|
+
0 && (module.exports = {
|
|
311
|
+
injectSeoFields
|
|
312
|
+
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// libs/core/src/lib/collections/define-collection.ts
|
|
2
|
+
function defineCollection(config) {
|
|
3
|
+
const collection = {
|
|
4
|
+
timestamps: true,
|
|
5
|
+
// Enable timestamps by default
|
|
6
|
+
...config
|
|
7
|
+
};
|
|
8
|
+
if (!collection.slug) {
|
|
9
|
+
throw new Error("Collection must have a slug");
|
|
10
|
+
}
|
|
11
|
+
if (!collection.fields || collection.fields.length === 0) {
|
|
12
|
+
throw new Error(`Collection "${collection.slug}" must have at least one field`);
|
|
13
|
+
}
|
|
14
|
+
if (!/^[a-z][a-z0-9-]*$/.test(collection.slug)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Collection slug "${collection.slug}" must be kebab-case (lowercase letters, numbers, and hyphens, starting with a letter)`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return collection;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// libs/core/src/lib/fields/field-builders.ts
|
|
23
|
+
function text(name, options = {}) {
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
type: "text",
|
|
27
|
+
...options
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function textarea(name, options = {}) {
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
type: "textarea",
|
|
34
|
+
...options
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function number(name, options = {}) {
|
|
38
|
+
return {
|
|
39
|
+
name,
|
|
40
|
+
type: "number",
|
|
41
|
+
...options
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function checkbox(name, options = {}) {
|
|
45
|
+
return {
|
|
46
|
+
name,
|
|
47
|
+
type: "checkbox",
|
|
48
|
+
...options,
|
|
49
|
+
defaultValue: options.defaultValue ?? false
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function select(name, options) {
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
type: "select",
|
|
56
|
+
...options
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function upload(name, options = {}) {
|
|
60
|
+
return {
|
|
61
|
+
name,
|
|
62
|
+
type: "upload",
|
|
63
|
+
relationTo: options.relationTo ?? "media",
|
|
64
|
+
...options
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function json(name, options = {}) {
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
type: "json",
|
|
71
|
+
...options
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function tabs(name, options) {
|
|
75
|
+
return {
|
|
76
|
+
name,
|
|
77
|
+
type: "tabs",
|
|
78
|
+
...options
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// libs/core/src/lib/collections/media.collection.ts
|
|
83
|
+
var MediaCollection = defineCollection({
|
|
84
|
+
slug: "media",
|
|
85
|
+
labels: {
|
|
86
|
+
singular: "Media",
|
|
87
|
+
plural: "Media"
|
|
88
|
+
},
|
|
89
|
+
upload: {
|
|
90
|
+
mimeTypes: ["image/*", "application/pdf", "video/*", "audio/*"]
|
|
91
|
+
},
|
|
92
|
+
admin: {
|
|
93
|
+
useAsTitle: "filename",
|
|
94
|
+
defaultColumns: ["filename", "mimeType", "filesize", "createdAt"]
|
|
95
|
+
},
|
|
96
|
+
fields: [
|
|
97
|
+
text("filename", {
|
|
98
|
+
required: true,
|
|
99
|
+
label: "Filename",
|
|
100
|
+
description: "Original filename of the uploaded file"
|
|
101
|
+
}),
|
|
102
|
+
text("mimeType", {
|
|
103
|
+
required: true,
|
|
104
|
+
label: "MIME Type",
|
|
105
|
+
description: "File MIME type (e.g., image/jpeg, application/pdf)"
|
|
106
|
+
}),
|
|
107
|
+
number("filesize", {
|
|
108
|
+
label: "File Size",
|
|
109
|
+
description: "File size in bytes"
|
|
110
|
+
}),
|
|
111
|
+
text("path", {
|
|
112
|
+
label: "Storage Path",
|
|
113
|
+
description: "Path/key where the file is stored",
|
|
114
|
+
admin: {
|
|
115
|
+
hidden: true
|
|
116
|
+
}
|
|
117
|
+
}),
|
|
118
|
+
text("url", {
|
|
119
|
+
label: "URL",
|
|
120
|
+
description: "Public URL to access the file"
|
|
121
|
+
}),
|
|
122
|
+
text("alt", {
|
|
123
|
+
label: "Alt Text",
|
|
124
|
+
description: "Alternative text for accessibility"
|
|
125
|
+
}),
|
|
126
|
+
number("width", {
|
|
127
|
+
label: "Width",
|
|
128
|
+
description: "Image width in pixels (for images only)"
|
|
129
|
+
}),
|
|
130
|
+
number("height", {
|
|
131
|
+
label: "Height",
|
|
132
|
+
description: "Image height in pixels (for images only)"
|
|
133
|
+
}),
|
|
134
|
+
json("focalPoint", {
|
|
135
|
+
label: "Focal Point",
|
|
136
|
+
description: "Focal point coordinates for image cropping",
|
|
137
|
+
admin: {
|
|
138
|
+
hidden: true
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
],
|
|
142
|
+
access: {
|
|
143
|
+
// Media is readable by anyone by default
|
|
144
|
+
read: () => true,
|
|
145
|
+
// Only authenticated users can create/update/delete
|
|
146
|
+
create: ({ req }) => !!req?.user,
|
|
147
|
+
update: ({ req }) => !!req?.user,
|
|
148
|
+
delete: ({ req }) => !!req?.user
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// libs/plugins/seo/src/lib/seo-fields.ts
|
|
153
|
+
function getSeoFields() {
|
|
154
|
+
return [
|
|
155
|
+
text("metaTitle", {
|
|
156
|
+
label: "Meta Title",
|
|
157
|
+
maxLength: 70,
|
|
158
|
+
description: "Title tag for search engines (50-60 chars recommended)"
|
|
159
|
+
}),
|
|
160
|
+
textarea("metaDescription", {
|
|
161
|
+
label: "Meta Description",
|
|
162
|
+
maxLength: 160,
|
|
163
|
+
rows: 3,
|
|
164
|
+
description: "Description for search results (120-155 chars recommended)"
|
|
165
|
+
}),
|
|
166
|
+
text("canonicalUrl", {
|
|
167
|
+
label: "Canonical URL",
|
|
168
|
+
description: "Override the canonical URL for this page"
|
|
169
|
+
}),
|
|
170
|
+
text("focusKeyword", {
|
|
171
|
+
label: "Focus Keyword",
|
|
172
|
+
description: "Primary keyword to optimize this content for"
|
|
173
|
+
}),
|
|
174
|
+
text("ogTitle", {
|
|
175
|
+
label: "OG Title",
|
|
176
|
+
description: "Open Graph title (falls back to Meta Title)"
|
|
177
|
+
}),
|
|
178
|
+
textarea("ogDescription", {
|
|
179
|
+
label: "OG Description",
|
|
180
|
+
rows: 2,
|
|
181
|
+
description: "Open Graph description (falls back to Meta Description)"
|
|
182
|
+
}),
|
|
183
|
+
upload("ogImage", {
|
|
184
|
+
label: "OG Image",
|
|
185
|
+
relationTo: "media",
|
|
186
|
+
mimeTypes: ["image/*"],
|
|
187
|
+
description: "Recommended size: 1200x630px"
|
|
188
|
+
}),
|
|
189
|
+
select("ogType", {
|
|
190
|
+
label: "OG Type",
|
|
191
|
+
options: [
|
|
192
|
+
{ label: "Website", value: "website" },
|
|
193
|
+
{ label: "Article", value: "article" },
|
|
194
|
+
{ label: "Product", value: "product" },
|
|
195
|
+
{ label: "Profile", value: "profile" }
|
|
196
|
+
],
|
|
197
|
+
defaultValue: "website"
|
|
198
|
+
}),
|
|
199
|
+
select("twitterCard", {
|
|
200
|
+
label: "Twitter Card",
|
|
201
|
+
options: [
|
|
202
|
+
{ label: "Summary", value: "summary" },
|
|
203
|
+
{ label: "Summary Large Image", value: "summary_large_image" },
|
|
204
|
+
{ label: "Player", value: "player" },
|
|
205
|
+
{ label: "App", value: "app" }
|
|
206
|
+
],
|
|
207
|
+
defaultValue: "summary_large_image"
|
|
208
|
+
}),
|
|
209
|
+
checkbox("noIndex", {
|
|
210
|
+
label: "No Index",
|
|
211
|
+
description: "Tell search engines not to index this page"
|
|
212
|
+
}),
|
|
213
|
+
checkbox("noFollow", {
|
|
214
|
+
label: "No Follow",
|
|
215
|
+
description: "Tell search engines not to follow links on this page"
|
|
216
|
+
}),
|
|
217
|
+
checkbox("excludeFromSitemap", {
|
|
218
|
+
label: "Exclude from Sitemap",
|
|
219
|
+
description: "Exclude this page from the XML sitemap without affecting search engine indexing"
|
|
220
|
+
}),
|
|
221
|
+
json("structuredData", {
|
|
222
|
+
label: "Structured Data (JSON-LD)",
|
|
223
|
+
description: "Custom JSON-LD structured data for this page"
|
|
224
|
+
})
|
|
225
|
+
];
|
|
226
|
+
}
|
|
227
|
+
function createSeoTabConfig() {
|
|
228
|
+
return {
|
|
229
|
+
name: "seo",
|
|
230
|
+
label: "SEO",
|
|
231
|
+
description: "Search engine optimization settings",
|
|
232
|
+
fields: getSeoFields()
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// libs/plugins/seo/src/lib/seo-utils.ts
|
|
237
|
+
function hasSeoField(collection) {
|
|
238
|
+
for (const field of collection.fields) {
|
|
239
|
+
if (field.name === "seo" && field.type === "group")
|
|
240
|
+
return true;
|
|
241
|
+
if (field.type === "tabs") {
|
|
242
|
+
const tabsField = field;
|
|
243
|
+
if (tabsField.tabs.some((t) => t.name === "seo"))
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// libs/plugins/seo/src/lib/seo-field-injector.ts
|
|
251
|
+
function shouldInject(collection, options) {
|
|
252
|
+
if (collection.managed)
|
|
253
|
+
return false;
|
|
254
|
+
if (collection.slug.startsWith("seo-"))
|
|
255
|
+
return false;
|
|
256
|
+
if (options.collections === "*") {
|
|
257
|
+
return !(options.excludeCollections ?? []).includes(collection.slug);
|
|
258
|
+
}
|
|
259
|
+
return options.collections.includes(collection.slug);
|
|
260
|
+
}
|
|
261
|
+
function findTopLevelTabs(collection) {
|
|
262
|
+
return collection.fields.find((f) => f.type === "tabs");
|
|
263
|
+
}
|
|
264
|
+
function injectSeoFields(collections, options) {
|
|
265
|
+
const seoTab = createSeoTabConfig();
|
|
266
|
+
for (const collection of collections) {
|
|
267
|
+
if (!shouldInject(collection, options))
|
|
268
|
+
continue;
|
|
269
|
+
if (hasSeoField(collection))
|
|
270
|
+
continue;
|
|
271
|
+
const existingTabs = findTopLevelTabs(collection);
|
|
272
|
+
if (existingTabs) {
|
|
273
|
+
existingTabs.tabs.push(seoTab);
|
|
274
|
+
} else {
|
|
275
|
+
const originalFields = [...collection.fields];
|
|
276
|
+
const tabsField = tabs("seoTabs", {
|
|
277
|
+
tabs: [{ label: "Content", fields: originalFields }, seoTab]
|
|
278
|
+
});
|
|
279
|
+
collection.fields = [tabsField];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
export {
|
|
284
|
+
injectSeoFields
|
|
285
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@momentumcms/plugins-seo",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "SEO plugin for Momentum CMS — meta tags, sitemap, robots.txt, analysis",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Momentum CMS Contributors",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/DonaldMurillo/momentum-cms.git",
|
|
10
|
+
"directory": "libs/plugins/seo"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/DonaldMurillo/momentum-cms#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/DonaldMurillo/momentum-cms/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cms",
|
|
18
|
+
"momentum-cms",
|
|
19
|
+
"seo",
|
|
20
|
+
"sitemap",
|
|
21
|
+
"meta-tags",
|
|
22
|
+
"robots-txt",
|
|
23
|
+
"open-graph"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
},
|
|
28
|
+
"main": "./index.cjs",
|
|
29
|
+
"types": "./src/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./src/index.d.ts",
|
|
33
|
+
"default": "./index.cjs"
|
|
34
|
+
},
|
|
35
|
+
"./admin-routes": {
|
|
36
|
+
"types": "./src/lib/seo-admin-routes.d.ts",
|
|
37
|
+
"default": "./lib/seo-admin-routes.cjs"
|
|
38
|
+
},
|
|
39
|
+
"./fields": {
|
|
40
|
+
"types": "./src/lib/seo-field-injector.d.ts",
|
|
41
|
+
"default": "./lib/seo-field-injector.cjs"
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@angular/common": "^21.0.0",
|
|
46
|
+
"@angular/core": "^21.0.0",
|
|
47
|
+
"@momentumcms/core": "0.4.0",
|
|
48
|
+
"@momentumcms/plugins-core": "0.4.0",
|
|
49
|
+
"@momentumcms/ui": "0.4.0",
|
|
50
|
+
"@ng-icons/core": "^33.0.0",
|
|
51
|
+
"@ng-icons/heroicons": "^33.0.0",
|
|
52
|
+
"express": "^4.21.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @momentumcms/plugins/seo
|
|
3
|
+
*
|
|
4
|
+
* SEO plugin for Momentum CMS.
|
|
5
|
+
* Provides meta tags, sitemap, robots.txt, and content analysis.
|
|
6
|
+
*/
|
|
7
|
+
export { seoPlugin } from './lib/seo-plugin';
|
|
8
|
+
export type { SeoPluginConfig, SeoAnalysisConfig, SeoScoringRule, SeoScoringContext, SeoFieldData, SitemapConfig, SitemapChangeFreq, RobotsConfig, RobotsRule, } from './lib/seo-config.types';
|
|
9
|
+
export type { SeoAnalysisResult, SeoRuleResult, MetaTags } from './lib/analysis/seo-analysis.types';
|
|
10
|
+
export { injectSeoFields } from './lib/seo-field-injector';
|
|
11
|
+
export type { SeoFieldInjectorOptions } from './lib/seo-field-injector';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SEO Analysis Hooks
|
|
3
|
+
*
|
|
4
|
+
* Injects afterChange hooks into SEO-enabled collections to trigger
|
|
5
|
+
* asynchronous content analysis after document saves.
|
|
6
|
+
*/
|
|
7
|
+
import type { CollectionConfig } from '@momentumcms/core';
|
|
8
|
+
import type { MomentumAPI } from '@momentumcms/plugins/core';
|
|
9
|
+
import type { SeoAnalysisConfig } from '../seo-config.types';
|
|
10
|
+
/**
|
|
11
|
+
* Inject afterChange hooks for SEO analysis into eligible collections.
|
|
12
|
+
*/
|
|
13
|
+
export declare function injectSeoAnalysisHooks(collections: CollectionConfig[], config: SeoAnalysisConfig, getApi: () => MomentumAPI | null): void;
|