@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.
@@ -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,7 @@
1
+ /**
2
+ * SEO Analysis Collection
3
+ *
4
+ * Stores SEO analysis results per document.
5
+ * Hidden from sidebar — accessed via SEO dashboard.
6
+ */
7
+ export declare const SeoAnalysis: import("@momentumcms/core").CollectionConfig;
@@ -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;