@jgardner04/ghost-mcp-server 1.4.0 → 1.6.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/README.md +70 -20
- package/package.json +1 -1
- package/src/__tests__/mcp_server_pages.test.js +520 -0
- package/src/mcp_server_improved.js +467 -1
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +306 -0
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +561 -0
- package/src/services/__tests__/newsletterService.test.js +217 -0
- package/src/services/__tests__/pageService.test.js +400 -0
- package/src/services/ghostServiceImproved.js +371 -0
- package/src/services/newsletterService.js +47 -0
- package/src/services/pageService.js +121 -0
|
@@ -185,6 +185,57 @@ const validators = {
|
|
|
185
185
|
throw new NotFoundError('Image file', imagePath);
|
|
186
186
|
}
|
|
187
187
|
},
|
|
188
|
+
|
|
189
|
+
validatePageData(pageData) {
|
|
190
|
+
const errors = [];
|
|
191
|
+
|
|
192
|
+
if (!pageData.title || pageData.title.trim().length === 0) {
|
|
193
|
+
errors.push({ field: 'title', message: 'Title is required' });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (!pageData.html && !pageData.mobiledoc) {
|
|
197
|
+
errors.push({ field: 'content', message: 'Either html or mobiledoc content is required' });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (pageData.status && !['draft', 'published', 'scheduled'].includes(pageData.status)) {
|
|
201
|
+
errors.push({
|
|
202
|
+
field: 'status',
|
|
203
|
+
message: 'Invalid status. Must be draft, published, or scheduled',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (pageData.status === 'scheduled' && !pageData.published_at) {
|
|
208
|
+
errors.push({
|
|
209
|
+
field: 'published_at',
|
|
210
|
+
message: 'published_at is required when status is scheduled',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (pageData.published_at) {
|
|
215
|
+
const publishDate = new Date(pageData.published_at);
|
|
216
|
+
if (isNaN(publishDate.getTime())) {
|
|
217
|
+
errors.push({ field: 'published_at', message: 'Invalid date format' });
|
|
218
|
+
} else if (pageData.status === 'scheduled' && publishDate <= new Date()) {
|
|
219
|
+
errors.push({ field: 'published_at', message: 'Scheduled date must be in the future' });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (errors.length > 0) {
|
|
224
|
+
throw new ValidationError('Page validation failed', errors);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
validateNewsletterData(newsletterData) {
|
|
229
|
+
const errors = [];
|
|
230
|
+
|
|
231
|
+
if (!newsletterData.name || newsletterData.name.trim().length === 0) {
|
|
232
|
+
errors.push({ field: 'name', message: 'Newsletter name is required' });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (errors.length > 0) {
|
|
236
|
+
throw new ValidationError('Newsletter validation failed', errors);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
188
239
|
};
|
|
189
240
|
|
|
190
241
|
/**
|
|
@@ -367,6 +418,218 @@ export async function searchPosts(query, options = {}) {
|
|
|
367
418
|
}
|
|
368
419
|
}
|
|
369
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Page CRUD Operations
|
|
423
|
+
* Pages are similar to posts but do NOT support tags
|
|
424
|
+
*/
|
|
425
|
+
|
|
426
|
+
export async function createPage(pageData, options = { source: 'html' }) {
|
|
427
|
+
// Validate input
|
|
428
|
+
validators.validatePageData(pageData);
|
|
429
|
+
|
|
430
|
+
// Add defaults
|
|
431
|
+
const dataWithDefaults = {
|
|
432
|
+
status: 'draft',
|
|
433
|
+
...pageData,
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// Sanitize HTML content if provided (use same sanitization as posts)
|
|
437
|
+
if (dataWithDefaults.html) {
|
|
438
|
+
dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
|
|
439
|
+
allowedTags: [
|
|
440
|
+
'h1',
|
|
441
|
+
'h2',
|
|
442
|
+
'h3',
|
|
443
|
+
'h4',
|
|
444
|
+
'h5',
|
|
445
|
+
'h6',
|
|
446
|
+
'blockquote',
|
|
447
|
+
'p',
|
|
448
|
+
'a',
|
|
449
|
+
'ul',
|
|
450
|
+
'ol',
|
|
451
|
+
'nl',
|
|
452
|
+
'li',
|
|
453
|
+
'b',
|
|
454
|
+
'i',
|
|
455
|
+
'strong',
|
|
456
|
+
'em',
|
|
457
|
+
'strike',
|
|
458
|
+
'code',
|
|
459
|
+
'hr',
|
|
460
|
+
'br',
|
|
461
|
+
'div',
|
|
462
|
+
'span',
|
|
463
|
+
'img',
|
|
464
|
+
'pre',
|
|
465
|
+
],
|
|
466
|
+
allowedAttributes: {
|
|
467
|
+
a: ['href', 'title'],
|
|
468
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
469
|
+
'*': ['class', 'id'],
|
|
470
|
+
},
|
|
471
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
472
|
+
allowedSchemesByTag: {
|
|
473
|
+
img: ['http', 'https', 'data'],
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
return await handleApiRequest('pages', 'add', dataWithDefaults, options);
|
|
480
|
+
} catch (error) {
|
|
481
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
482
|
+
throw new ValidationError('Page creation failed due to validation errors', [
|
|
483
|
+
{ field: 'page', message: error.originalError },
|
|
484
|
+
]);
|
|
485
|
+
}
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function updatePage(pageId, updateData, options = {}) {
|
|
491
|
+
if (!pageId) {
|
|
492
|
+
throw new ValidationError('Page ID is required for update');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Sanitize HTML if being updated
|
|
496
|
+
if (updateData.html) {
|
|
497
|
+
updateData.html = sanitizeHtml(updateData.html, {
|
|
498
|
+
allowedTags: [
|
|
499
|
+
'h1',
|
|
500
|
+
'h2',
|
|
501
|
+
'h3',
|
|
502
|
+
'h4',
|
|
503
|
+
'h5',
|
|
504
|
+
'h6',
|
|
505
|
+
'blockquote',
|
|
506
|
+
'p',
|
|
507
|
+
'a',
|
|
508
|
+
'ul',
|
|
509
|
+
'ol',
|
|
510
|
+
'nl',
|
|
511
|
+
'li',
|
|
512
|
+
'b',
|
|
513
|
+
'i',
|
|
514
|
+
'strong',
|
|
515
|
+
'em',
|
|
516
|
+
'strike',
|
|
517
|
+
'code',
|
|
518
|
+
'hr',
|
|
519
|
+
'br',
|
|
520
|
+
'div',
|
|
521
|
+
'span',
|
|
522
|
+
'img',
|
|
523
|
+
'pre',
|
|
524
|
+
],
|
|
525
|
+
allowedAttributes: {
|
|
526
|
+
a: ['href', 'title'],
|
|
527
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
528
|
+
'*': ['class', 'id'],
|
|
529
|
+
},
|
|
530
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
531
|
+
allowedSchemesByTag: {
|
|
532
|
+
img: ['http', 'https', 'data'],
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
try {
|
|
538
|
+
// Get existing page to retrieve updated_at for conflict resolution
|
|
539
|
+
const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
|
|
540
|
+
|
|
541
|
+
// Merge existing data with updates, preserving updated_at
|
|
542
|
+
const mergedData = {
|
|
543
|
+
...existingPage,
|
|
544
|
+
...updateData,
|
|
545
|
+
updated_at: existingPage.updated_at,
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
return await handleApiRequest('pages', 'edit', mergedData, { id: pageId, ...options });
|
|
549
|
+
} catch (error) {
|
|
550
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
551
|
+
throw new NotFoundError('Page', pageId);
|
|
552
|
+
}
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export async function deletePage(pageId) {
|
|
558
|
+
if (!pageId) {
|
|
559
|
+
throw new ValidationError('Page ID is required for delete');
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
try {
|
|
563
|
+
return await handleApiRequest('pages', 'delete', { id: pageId });
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
566
|
+
throw new NotFoundError('Page', pageId);
|
|
567
|
+
}
|
|
568
|
+
throw error;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export async function getPage(pageId, options = {}) {
|
|
573
|
+
if (!pageId) {
|
|
574
|
+
throw new ValidationError('Page ID is required');
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
return await handleApiRequest('pages', 'read', { id: pageId }, options);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
581
|
+
throw new NotFoundError('Page', pageId);
|
|
582
|
+
}
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export async function getPages(options = {}) {
|
|
588
|
+
const defaultOptions = {
|
|
589
|
+
limit: 15,
|
|
590
|
+
include: 'authors',
|
|
591
|
+
...options,
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
return await handleApiRequest('pages', 'browse', {}, defaultOptions);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
console.error('Failed to get pages:', error);
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export async function searchPages(query, options = {}) {
|
|
603
|
+
// Validate query
|
|
604
|
+
if (!query || query.trim().length === 0) {
|
|
605
|
+
throw new ValidationError('Search query is required');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Sanitize query - escape special NQL characters to prevent injection
|
|
609
|
+
const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
610
|
+
|
|
611
|
+
// Build filter with fuzzy title match using Ghost NQL
|
|
612
|
+
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
613
|
+
|
|
614
|
+
// Add status filter if provided and not 'all'
|
|
615
|
+
if (options.status && options.status !== 'all') {
|
|
616
|
+
filterParts.push(`status:${options.status}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const searchOptions = {
|
|
620
|
+
limit: options.limit || 15,
|
|
621
|
+
include: 'authors',
|
|
622
|
+
filter: filterParts.join('+'),
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
return await handleApiRequest('pages', 'browse', {}, searchOptions);
|
|
627
|
+
} catch (error) {
|
|
628
|
+
console.error('Failed to search pages:', error);
|
|
629
|
+
throw error;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
370
633
|
export async function uploadImage(imagePath) {
|
|
371
634
|
// Validate input
|
|
372
635
|
await validators.validateImagePath(imagePath);
|
|
@@ -488,6 +751,103 @@ export async function deleteTag(tagId) {
|
|
|
488
751
|
}
|
|
489
752
|
}
|
|
490
753
|
|
|
754
|
+
/**
|
|
755
|
+
* Newsletter CRUD Operations
|
|
756
|
+
*/
|
|
757
|
+
|
|
758
|
+
export async function getNewsletters(options = {}) {
|
|
759
|
+
const defaultOptions = {
|
|
760
|
+
limit: 'all',
|
|
761
|
+
...options,
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
try {
|
|
765
|
+
const newsletters = await handleApiRequest('newsletters', 'browse', {}, defaultOptions);
|
|
766
|
+
return newsletters || [];
|
|
767
|
+
} catch (error) {
|
|
768
|
+
console.error('Failed to get newsletters:', error);
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export async function getNewsletter(newsletterId) {
|
|
774
|
+
if (!newsletterId) {
|
|
775
|
+
throw new ValidationError('Newsletter ID is required');
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
try {
|
|
779
|
+
return await handleApiRequest('newsletters', 'read', { id: newsletterId });
|
|
780
|
+
} catch (error) {
|
|
781
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
782
|
+
throw new NotFoundError('Newsletter', newsletterId);
|
|
783
|
+
}
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export async function createNewsletter(newsletterData) {
|
|
789
|
+
// Validate input
|
|
790
|
+
validators.validateNewsletterData(newsletterData);
|
|
791
|
+
|
|
792
|
+
try {
|
|
793
|
+
return await handleApiRequest('newsletters', 'add', newsletterData);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
796
|
+
throw new ValidationError('Newsletter creation failed', [
|
|
797
|
+
{ field: 'newsletter', message: error.originalError },
|
|
798
|
+
]);
|
|
799
|
+
}
|
|
800
|
+
throw error;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export async function updateNewsletter(newsletterId, updateData) {
|
|
805
|
+
if (!newsletterId) {
|
|
806
|
+
throw new ValidationError('Newsletter ID is required for update');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
try {
|
|
810
|
+
// Get existing newsletter to retrieve updated_at for conflict resolution
|
|
811
|
+
const existingNewsletter = await handleApiRequest('newsletters', 'read', {
|
|
812
|
+
id: newsletterId,
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
// Merge existing data with updates, preserving updated_at
|
|
816
|
+
const mergedData = {
|
|
817
|
+
...existingNewsletter,
|
|
818
|
+
...updateData,
|
|
819
|
+
updated_at: existingNewsletter.updated_at,
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
return await handleApiRequest('newsletters', 'edit', mergedData, { id: newsletterId });
|
|
823
|
+
} catch (error) {
|
|
824
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
825
|
+
throw new NotFoundError('Newsletter', newsletterId);
|
|
826
|
+
}
|
|
827
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
828
|
+
throw new ValidationError('Newsletter update failed', [
|
|
829
|
+
{ field: 'newsletter', message: error.originalError },
|
|
830
|
+
]);
|
|
831
|
+
}
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
export async function deleteNewsletter(newsletterId) {
|
|
837
|
+
if (!newsletterId) {
|
|
838
|
+
throw new ValidationError('Newsletter ID is required for deletion');
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
try {
|
|
842
|
+
return await handleApiRequest('newsletters', 'delete', newsletterId);
|
|
843
|
+
} catch (error) {
|
|
844
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
845
|
+
throw new NotFoundError('Newsletter', newsletterId);
|
|
846
|
+
}
|
|
847
|
+
throw error;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
491
851
|
/**
|
|
492
852
|
* Health check for Ghost API connection
|
|
493
853
|
*/
|
|
@@ -527,11 +887,22 @@ export default {
|
|
|
527
887
|
getPost,
|
|
528
888
|
getPosts,
|
|
529
889
|
searchPosts,
|
|
890
|
+
createPage,
|
|
891
|
+
updatePage,
|
|
892
|
+
deletePage,
|
|
893
|
+
getPage,
|
|
894
|
+
getPages,
|
|
895
|
+
searchPages,
|
|
530
896
|
uploadImage,
|
|
531
897
|
createTag,
|
|
532
898
|
getTags,
|
|
533
899
|
getTag,
|
|
534
900
|
updateTag,
|
|
535
901
|
deleteTag,
|
|
902
|
+
getNewsletters,
|
|
903
|
+
getNewsletter,
|
|
904
|
+
createNewsletter,
|
|
905
|
+
updateNewsletter,
|
|
906
|
+
deleteNewsletter,
|
|
536
907
|
checkHealth,
|
|
537
908
|
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Joi from 'joi';
|
|
2
|
+
import { createContextLogger } from '../utils/logger.js';
|
|
3
|
+
import { createNewsletter as createGhostNewsletter } from './ghostServiceImproved.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validation schema for newsletter input
|
|
7
|
+
*/
|
|
8
|
+
const newsletterInputSchema = Joi.object({
|
|
9
|
+
name: Joi.string().required(),
|
|
10
|
+
description: Joi.string().optional(),
|
|
11
|
+
sender_name: Joi.string().optional(),
|
|
12
|
+
sender_email: Joi.string().email().optional(),
|
|
13
|
+
sender_reply_to: Joi.string().valid('newsletter', 'support').optional(),
|
|
14
|
+
subscribe_on_signup: Joi.boolean().strict().optional(),
|
|
15
|
+
show_header_icon: Joi.boolean().strict().optional(),
|
|
16
|
+
show_header_title: Joi.boolean().strict().optional(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Service layer function to handle the business logic of creating a newsletter.
|
|
21
|
+
* Validates input and creates a newsletter in Ghost CMS.
|
|
22
|
+
* @param {object} newsletterInput - Data received from the controller.
|
|
23
|
+
* @returns {Promise<object>} The created newsletter object from the Ghost API.
|
|
24
|
+
*/
|
|
25
|
+
const createNewsletterService = async (newsletterInput) => {
|
|
26
|
+
const logger = createContextLogger('newsletter-service');
|
|
27
|
+
|
|
28
|
+
// Validate input
|
|
29
|
+
const { error, value: validatedInput } = newsletterInputSchema.validate(newsletterInput);
|
|
30
|
+
if (error) {
|
|
31
|
+
logger.error('Newsletter input validation failed', {
|
|
32
|
+
error: error.details[0].message,
|
|
33
|
+
inputKeys: Object.keys(newsletterInput),
|
|
34
|
+
});
|
|
35
|
+
throw new Error(`Invalid newsletter input: ${error.details[0].message}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
logger.info('Creating Ghost newsletter', {
|
|
39
|
+
name: validatedInput.name,
|
|
40
|
+
hasSenderEmail: !!validatedInput.sender_email,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const newNewsletter = await createGhostNewsletter(validatedInput);
|
|
44
|
+
return newNewsletter;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export { createNewsletterService };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import sanitizeHtml from 'sanitize-html';
|
|
2
|
+
import Joi from 'joi';
|
|
3
|
+
import { createContextLogger } from '../utils/logger.js';
|
|
4
|
+
import { createPage as createGhostPage } from './ghostServiceImproved.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Helper to generate a simple meta description from HTML content.
|
|
8
|
+
* Uses sanitize-html to safely strip HTML tags and truncates.
|
|
9
|
+
* @param {string} htmlContent - The HTML content of the page.
|
|
10
|
+
* @param {number} maxLength - The maximum length of the description.
|
|
11
|
+
* @returns {string} A plain text truncated description.
|
|
12
|
+
*/
|
|
13
|
+
const generateSimpleMetaDescription = (htmlContent, maxLength = 500) => {
|
|
14
|
+
if (!htmlContent) return '';
|
|
15
|
+
|
|
16
|
+
// Use sanitize-html to safely remove all HTML tags
|
|
17
|
+
// This prevents ReDoS attacks and properly handles malformed HTML
|
|
18
|
+
const textContent = sanitizeHtml(htmlContent, {
|
|
19
|
+
allowedTags: [], // Remove all HTML tags
|
|
20
|
+
allowedAttributes: {},
|
|
21
|
+
textFilter: function (text) {
|
|
22
|
+
return text.replace(/\s\s+/g, ' ').trim();
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Truncate and add ellipsis if needed
|
|
27
|
+
return textContent.length > maxLength
|
|
28
|
+
? textContent.substring(0, maxLength - 3) + '...'
|
|
29
|
+
: textContent;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validation schema for page input
|
|
34
|
+
* Pages are similar to posts but do NOT support tags
|
|
35
|
+
*/
|
|
36
|
+
const pageInputSchema = Joi.object({
|
|
37
|
+
title: Joi.string().max(255).required(),
|
|
38
|
+
html: Joi.string().required(),
|
|
39
|
+
custom_excerpt: Joi.string().max(500).optional(),
|
|
40
|
+
status: Joi.string().valid('draft', 'published', 'scheduled').optional(),
|
|
41
|
+
published_at: Joi.string().isoDate().optional(),
|
|
42
|
+
// NO tags field - pages don't support tags
|
|
43
|
+
feature_image: Joi.string().uri().optional(),
|
|
44
|
+
feature_image_alt: Joi.string().max(255).optional(),
|
|
45
|
+
feature_image_caption: Joi.string().max(500).optional(),
|
|
46
|
+
meta_title: Joi.string().max(70).optional(),
|
|
47
|
+
meta_description: Joi.string().max(160).optional(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Service layer function to handle the business logic of creating a page.
|
|
52
|
+
* Transforms input data, generates metadata defaults.
|
|
53
|
+
* Note: Pages do NOT support tags (unlike posts).
|
|
54
|
+
* @param {object} pageInput - Data received from the controller.
|
|
55
|
+
* @returns {Promise<object>} The created page object from the Ghost API.
|
|
56
|
+
*/
|
|
57
|
+
const createPageService = async (pageInput) => {
|
|
58
|
+
const logger = createContextLogger('page-service');
|
|
59
|
+
|
|
60
|
+
// Validate input to prevent format string vulnerabilities
|
|
61
|
+
const { error, value: validatedInput } = pageInputSchema.validate(pageInput);
|
|
62
|
+
if (error) {
|
|
63
|
+
logger.error('Page input validation failed', {
|
|
64
|
+
error: error.details[0].message,
|
|
65
|
+
inputKeys: Object.keys(pageInput),
|
|
66
|
+
});
|
|
67
|
+
throw new Error(`Invalid page input: ${error.details[0].message}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const {
|
|
71
|
+
title,
|
|
72
|
+
html,
|
|
73
|
+
custom_excerpt,
|
|
74
|
+
status,
|
|
75
|
+
published_at,
|
|
76
|
+
// NO tags destructuring - pages don't support tags
|
|
77
|
+
feature_image,
|
|
78
|
+
feature_image_alt,
|
|
79
|
+
feature_image_caption,
|
|
80
|
+
meta_title,
|
|
81
|
+
meta_description,
|
|
82
|
+
} = validatedInput;
|
|
83
|
+
|
|
84
|
+
// NO tag resolution section (removed from postService)
|
|
85
|
+
// Pages do not support tags in Ghost CMS
|
|
86
|
+
|
|
87
|
+
// Metadata defaults
|
|
88
|
+
const finalMetaTitle = meta_title || title;
|
|
89
|
+
const finalMetaDescription =
|
|
90
|
+
meta_description || custom_excerpt || generateSimpleMetaDescription(html);
|
|
91
|
+
const truncatedMetaDescription =
|
|
92
|
+
finalMetaDescription.length > 500
|
|
93
|
+
? finalMetaDescription.substring(0, 497) + '...'
|
|
94
|
+
: finalMetaDescription;
|
|
95
|
+
|
|
96
|
+
// Prepare data for Ghost API
|
|
97
|
+
const pageDataForApi = {
|
|
98
|
+
title,
|
|
99
|
+
html,
|
|
100
|
+
custom_excerpt,
|
|
101
|
+
status: status || 'draft',
|
|
102
|
+
published_at,
|
|
103
|
+
// NO tags field
|
|
104
|
+
feature_image,
|
|
105
|
+
feature_image_alt,
|
|
106
|
+
feature_image_caption,
|
|
107
|
+
meta_title: finalMetaTitle,
|
|
108
|
+
meta_description: truncatedMetaDescription,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
logger.info('Creating Ghost page', {
|
|
112
|
+
title: pageDataForApi.title,
|
|
113
|
+
status: pageDataForApi.status,
|
|
114
|
+
hasFeatureImage: !!pageDataForApi.feature_image,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const newPage = await createGhostPage(pageDataForApi);
|
|
118
|
+
return newPage;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export { createPageService };
|