@jgardner04/ghost-mcp-server 1.13.4 → 1.14.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 +68 -0
- package/package.json +7 -3
- package/src/__tests__/helpers/testUtils.js +15 -1
- package/src/__tests__/mcp_server.test.js +152 -1
- package/src/__tests__/mcp_server_pages.test.js +23 -6
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +647 -1203
- package/src/routes/__tests__/imageRoutes.test.js +2 -2
- package/src/schemas/__tests__/common.test.js +3 -3
- package/src/schemas/__tests__/pageSchemas.test.js +11 -2
- package/src/schemas/common.js +3 -2
- package/src/schemas/pageSchemas.js +1 -1
- package/src/schemas/postSchemas.js +1 -1
- package/src/services/__tests__/createResourceService.test.js +468 -0
- package/src/services/__tests__/ghostService.test.js +0 -19
- package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/createResourceService.js +138 -0
- package/src/services/ghostApiClient.js +240 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/ghostServiceImproved.js +76 -915
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +54 -0
- package/src/services/members.js +127 -0
- package/src/services/newsletters.js +63 -0
- package/src/services/pageService.js +2 -2
- package/src/services/pages.js +116 -0
- package/src/services/posts.js +116 -0
- package/src/services/tags.js +118 -0
- package/src/services/tiers.js +72 -0
- package/src/services/validators.js +218 -0
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/imageInputResolver.js +127 -0
|
@@ -1,96 +1,140 @@
|
|
|
1
1
|
import sharp from 'sharp';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import
|
|
4
|
-
import
|
|
3
|
+
import fsp from 'fs/promises';
|
|
4
|
+
import { z } from 'zod';
|
|
5
5
|
import { createContextLogger } from '../utils/logger.js';
|
|
6
6
|
|
|
7
|
-
// Define processing parameters (e.g., max width)
|
|
8
7
|
const MAX_WIDTH = 1200;
|
|
9
|
-
const
|
|
8
|
+
const JPEG_QUALITY = 80;
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
// Formats we re-encode via sharp when the image is oversized.
|
|
11
|
+
// Everything else is passed through untouched to preserve fidelity
|
|
12
|
+
// (SVG vectors, GIF animation, ICO, exotic formats).
|
|
13
|
+
const RESIZABLE_FORMATS = new Set(['jpeg', 'png', 'webp']);
|
|
14
|
+
|
|
15
|
+
const EXT_BY_FORMAT = {
|
|
16
|
+
jpeg: '.jpg',
|
|
17
|
+
png: '.png',
|
|
18
|
+
webp: '.webp',
|
|
19
|
+
gif: '.gif',
|
|
20
|
+
svg: '.svg',
|
|
21
|
+
// ICO has no sharp `format` name; detected by extension.
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const processImageParamsSchema = z.object({
|
|
25
|
+
inputPath: z.string().min(1),
|
|
26
|
+
outputDir: z.string().min(1),
|
|
21
27
|
});
|
|
22
28
|
|
|
23
|
-
const
|
|
29
|
+
const processImageOptsSchema = z
|
|
30
|
+
.object({
|
|
31
|
+
purpose: z.enum(['image', 'profile_image', 'icon']).optional(),
|
|
32
|
+
})
|
|
33
|
+
.optional();
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Process an image for upload to Ghost.
|
|
37
|
+
*
|
|
38
|
+
* Preserves the original format. Resizes (preserving format) only for
|
|
39
|
+
* raster formats sharp can safely re-encode — JPEG, PNG, WEBP — when
|
|
40
|
+
* wider than MAX_WIDTH. SVG, GIF, ICO, and unrecognized formats are
|
|
41
|
+
* copied byte-for-byte so vectors, animation frames, and icon metadata
|
|
42
|
+
* survive the round trip.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} inputPath - Absolute path to the source image.
|
|
45
|
+
* @param {string} outputDir - Directory to write the processed file into.
|
|
46
|
+
* @param {{ purpose?: 'image'|'profile_image'|'icon' }} [opts]
|
|
47
|
+
* @returns {Promise<string>} Absolute path to the processed (or copied) file.
|
|
48
|
+
*/
|
|
49
|
+
export async function processImage(inputPath, outputDir, opts = {}) {
|
|
24
50
|
const logger = createContextLogger('image-processing');
|
|
25
51
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (error) {
|
|
29
|
-
logger.error('Invalid processing parameters', {
|
|
30
|
-
error: error.details[0].message,
|
|
31
|
-
inputPath: inputPath ? path.basename(inputPath) : 'undefined',
|
|
32
|
-
outputDir: outputDir ? path.basename(outputDir) : 'undefined',
|
|
33
|
-
});
|
|
52
|
+
const params = processImageParamsSchema.safeParse({ inputPath, outputDir });
|
|
53
|
+
if (!params.success) {
|
|
34
54
|
throw new Error('Invalid processing parameters');
|
|
35
55
|
}
|
|
56
|
+
processImageOptsSchema.parse(opts);
|
|
36
57
|
|
|
37
|
-
|
|
38
|
-
const resolvedInputPath = path.resolve(inputPath);
|
|
58
|
+
const resolvedInput = path.resolve(inputPath);
|
|
39
59
|
const resolvedOutputDir = path.resolve(outputDir);
|
|
40
60
|
|
|
41
|
-
|
|
42
|
-
|
|
61
|
+
try {
|
|
62
|
+
await fsp.access(resolvedInput);
|
|
63
|
+
} catch {
|
|
43
64
|
throw new Error('Input file does not exist');
|
|
44
65
|
}
|
|
45
66
|
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
// Use timestamp for unique output filename
|
|
67
|
+
const inputExt = path.extname(resolvedInput).toLowerCase();
|
|
68
|
+
const baseName = path.basename(resolvedInput, path.extname(resolvedInput));
|
|
49
69
|
const timestamp = Date.now();
|
|
50
|
-
const outputFilename = `processed-${timestamp}-${nameWithoutExt}.jpg`;
|
|
51
|
-
const outputPath = path.join(resolvedOutputDir, outputFilename);
|
|
52
70
|
|
|
53
71
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
72
|
+
// Non-raster passthrough: SVG and ICO are never handed to sharp.
|
|
73
|
+
// (sharp can read SVG but would rasterize it, destroying the vector.)
|
|
74
|
+
if (inputExt === '.svg' || inputExt === '.ico') {
|
|
75
|
+
return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, inputExt);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const image = sharp(resolvedInput);
|
|
59
79
|
const metadata = await image.metadata();
|
|
80
|
+
const format = metadata.format; // 'jpeg' | 'png' | 'webp' | 'gif' | 'svg' | ...
|
|
60
81
|
|
|
61
|
-
|
|
82
|
+
// GIF: never re-encode — sharp would drop animation frames.
|
|
83
|
+
// SVG (if it slipped through by extension mismatch): also passthrough.
|
|
84
|
+
if (format === 'gif' || format === 'svg') {
|
|
85
|
+
const ext = EXT_BY_FORMAT[format] || inputExt || '';
|
|
86
|
+
return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, ext);
|
|
87
|
+
}
|
|
62
88
|
|
|
63
|
-
//
|
|
64
|
-
if (
|
|
65
|
-
logger.info('
|
|
66
|
-
|
|
67
|
-
targetWidth: MAX_WIDTH,
|
|
89
|
+
// Unknown / unsupported format: safest is passthrough.
|
|
90
|
+
if (!RESIZABLE_FORMATS.has(format)) {
|
|
91
|
+
logger.info('Unknown format, passing through', {
|
|
92
|
+
format,
|
|
68
93
|
inputFile: path.basename(inputPath),
|
|
69
94
|
});
|
|
70
|
-
|
|
95
|
+
return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, inputExt);
|
|
71
96
|
}
|
|
72
97
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
await processedImage.jpeg({ quality: OUTPUT_QUALITY }).toFile(outputPath);
|
|
98
|
+
const ext = EXT_BY_FORMAT[format];
|
|
99
|
+
const outputPath = path.join(resolvedOutputDir, `processed-${timestamp}-${baseName}${ext}`);
|
|
76
100
|
|
|
77
|
-
|
|
101
|
+
// Passthrough when no resize is needed — avoids generation loss.
|
|
102
|
+
if (!metadata.width || metadata.width <= MAX_WIDTH) {
|
|
103
|
+
await fsp.copyFile(resolvedInput, outputPath);
|
|
104
|
+
logger.info('Image within size limits, passthrough copy', {
|
|
105
|
+
format,
|
|
106
|
+
width: metadata.width,
|
|
107
|
+
inputFile: path.basename(inputPath),
|
|
108
|
+
});
|
|
109
|
+
return outputPath;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
logger.info('Resizing image', {
|
|
113
|
+
format,
|
|
114
|
+
originalWidth: metadata.width,
|
|
115
|
+
targetWidth: MAX_WIDTH,
|
|
78
116
|
inputFile: path.basename(inputPath),
|
|
79
|
-
outputFile: path.basename(outputPath),
|
|
80
|
-
originalSize: metadata.size,
|
|
81
|
-
quality: OUTPUT_QUALITY,
|
|
82
117
|
});
|
|
118
|
+
|
|
119
|
+
// sharp picks the encoder from outputPath extension (EXT_BY_FORMAT),
|
|
120
|
+
// so only JPEG needs the explicit call here to set quality. PNG/WEBP
|
|
121
|
+
// would be redundant.
|
|
122
|
+
let pipeline = image.resize({ width: MAX_WIDTH });
|
|
123
|
+
if (format === 'jpeg') pipeline = pipeline.jpeg({ quality: JPEG_QUALITY });
|
|
124
|
+
|
|
125
|
+
await pipeline.toFile(outputPath);
|
|
83
126
|
return outputPath;
|
|
84
127
|
} catch (error) {
|
|
85
128
|
logger.error('Image processing failed', {
|
|
86
129
|
inputFile: path.basename(inputPath),
|
|
87
130
|
error: error.message,
|
|
88
|
-
stack: error.stack,
|
|
89
131
|
});
|
|
90
|
-
// If processing fails, maybe fall back to using the original?
|
|
91
|
-
// Or throw the error to fail the upload.
|
|
92
132
|
throw new Error('Image processing failed: ' + error.message, { cause: error });
|
|
93
133
|
}
|
|
94
|
-
}
|
|
134
|
+
}
|
|
95
135
|
|
|
96
|
-
|
|
136
|
+
async function passthrough(inputPath, outputDir, baseName, timestamp, ext) {
|
|
137
|
+
const outputPath = path.join(outputDir, `processed-${timestamp}-${baseName}${ext}`);
|
|
138
|
+
await fsp.copyFile(inputPath, outputPath);
|
|
139
|
+
return outputPath;
|
|
140
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { GhostAPIError, ValidationError } from '../errors/index.js';
|
|
2
|
+
import { handleApiRequest } from './ghostApiClient.js';
|
|
3
|
+
import { validators } from './validators.js';
|
|
4
|
+
|
|
5
|
+
const ALLOWED_PURPOSES = new Set(['image', 'profile_image', 'icon']);
|
|
6
|
+
const REF_MAX_LENGTH = 200;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Uploads an image to Ghost CMS from a local file path.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} imagePath - Absolute path to the image file.
|
|
12
|
+
* @param {object} [opts]
|
|
13
|
+
* @param {'image'|'profile_image'|'icon'} [opts.purpose] - Intended use.
|
|
14
|
+
* Ghost applies format/size validation per purpose (icon/profile_image
|
|
15
|
+
* must be square; icon also accepts ICO).
|
|
16
|
+
* @param {string} [opts.ref] - Caller-supplied identifier (e.g. original
|
|
17
|
+
* filename). Ghost echoes it back in the response. Max 200 chars.
|
|
18
|
+
* @returns {Promise<Object>} The uploaded image object with URL (and ref,
|
|
19
|
+
* if provided and the SDK forwards it).
|
|
20
|
+
* @throws {ValidationError} If inputs are invalid or upload fails.
|
|
21
|
+
* @throws {NotFoundError} If the file does not exist.
|
|
22
|
+
* @throws {GhostAPIError} If the API request fails.
|
|
23
|
+
*/
|
|
24
|
+
export async function uploadImage(imagePath, opts = {}) {
|
|
25
|
+
await validators.validateImagePath(imagePath);
|
|
26
|
+
|
|
27
|
+
const { purpose, ref } = opts;
|
|
28
|
+
if (purpose !== undefined && !ALLOWED_PURPOSES.has(purpose)) {
|
|
29
|
+
throw new ValidationError(
|
|
30
|
+
`Invalid purpose "${purpose}". Must be one of: ${[...ALLOWED_PURPOSES].join(', ')}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (ref !== undefined) {
|
|
34
|
+
if (typeof ref !== 'string') {
|
|
35
|
+
throw new ValidationError('ref must be a string');
|
|
36
|
+
}
|
|
37
|
+
if (ref.length > REF_MAX_LENGTH) {
|
|
38
|
+
throw new ValidationError(`ref cannot exceed ${REF_MAX_LENGTH} characters`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const imageData = { file: imagePath };
|
|
43
|
+
if (purpose !== undefined) imageData.purpose = purpose;
|
|
44
|
+
if (ref !== undefined) imageData.ref = ref;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
return await handleApiRequest('images', 'upload', imageData);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof GhostAPIError) {
|
|
50
|
+
throw new ValidationError(`Image upload failed: ${error.originalError}`);
|
|
51
|
+
}
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { GhostAPIError, NotFoundError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
3
|
+
import { handleApiRequest } from './ghostApiClient.js';
|
|
4
|
+
import { createResourceService } from './createResourceService.js';
|
|
5
|
+
|
|
6
|
+
const service = createResourceService({
|
|
7
|
+
resource: 'members',
|
|
8
|
+
label: 'Member',
|
|
9
|
+
listDefaults: { limit: 15 },
|
|
10
|
+
// Input validation is performed at the MCP tool layer using Zod schemas
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new member (subscriber) in Ghost CMS
|
|
15
|
+
* @param {Object} memberData - The member data
|
|
16
|
+
* @param {string} memberData.email - Member email (required)
|
|
17
|
+
* @param {string} [memberData.name] - Member name
|
|
18
|
+
* @param {string} [memberData.note] - Notes about the member (HTML will be sanitized)
|
|
19
|
+
* @param {string[]} [memberData.labels] - Array of label names
|
|
20
|
+
* @param {Object[]} [memberData.newsletters] - Array of newsletter objects with id
|
|
21
|
+
* @param {boolean} [memberData.subscribed] - Email subscription status
|
|
22
|
+
* @param {Object} [options] - Additional options for the API request
|
|
23
|
+
* @returns {Promise<Object>} The created member object
|
|
24
|
+
* @throws {ValidationError} If validation fails
|
|
25
|
+
* @throws {GhostAPIError} If the API request fails
|
|
26
|
+
*/
|
|
27
|
+
export const createMember = service.create;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Updates an existing member in Ghost CMS
|
|
31
|
+
* @param {string} memberId - The member ID to update
|
|
32
|
+
* @param {Object} updateData - The member update data
|
|
33
|
+
* @param {string} [updateData.email] - Member email
|
|
34
|
+
* @param {string} [updateData.name] - Member name
|
|
35
|
+
* @param {string} [updateData.note] - Notes about the member (HTML will be sanitized)
|
|
36
|
+
* @param {string[]} [updateData.labels] - Array of label names
|
|
37
|
+
* @param {Object[]} [updateData.newsletters] - Array of newsletter objects with id
|
|
38
|
+
* @param {boolean} [updateData.subscribed] - Email subscription status
|
|
39
|
+
* @param {Object} [options] - Additional options for the API request
|
|
40
|
+
* @returns {Promise<Object>} The updated member object
|
|
41
|
+
* @throws {ValidationError} If validation fails
|
|
42
|
+
* @throws {NotFoundError} If the member is not found
|
|
43
|
+
* @throws {GhostAPIError} If the API request fails
|
|
44
|
+
*/
|
|
45
|
+
export const updateMember = service.update;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Deletes a member from Ghost CMS
|
|
49
|
+
* @param {string} memberId - The member ID to delete
|
|
50
|
+
* @returns {Promise<Object>} Deletion confirmation object
|
|
51
|
+
* @throws {ValidationError} If member ID is not provided
|
|
52
|
+
* @throws {NotFoundError} If the member is not found
|
|
53
|
+
* @throws {GhostAPIError} If the API request fails
|
|
54
|
+
*/
|
|
55
|
+
export const deleteMember = service.remove;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* List members from Ghost CMS with optional filtering and pagination
|
|
59
|
+
* @param {Object} [options] - Query options
|
|
60
|
+
* @param {number} [options.limit] - Number of members to return (1-100)
|
|
61
|
+
* @param {number} [options.page] - Page number (1+)
|
|
62
|
+
* @param {string} [options.filter] - NQL filter string (e.g., 'status:paid')
|
|
63
|
+
* @param {string} [options.order] - Order string (e.g., 'created_at desc')
|
|
64
|
+
* @param {string} [options.include] - Include string (e.g., 'labels,newsletters')
|
|
65
|
+
* @returns {Promise<Array>} Array of member objects
|
|
66
|
+
* @throws {ValidationError} If validation fails
|
|
67
|
+
* @throws {GhostAPIError} If the API request fails
|
|
68
|
+
*/
|
|
69
|
+
export const getMembers = service.getList;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get a single member from Ghost CMS by ID or email
|
|
73
|
+
* @param {Object} params - Lookup parameters (id OR email required)
|
|
74
|
+
* @param {string} [params.id] - Member ID
|
|
75
|
+
* @param {string} [params.email] - Member email
|
|
76
|
+
* @returns {Promise<Object>} The member object
|
|
77
|
+
* @throws {ValidationError} If validation fails
|
|
78
|
+
* @throws {NotFoundError} If the member is not found
|
|
79
|
+
* @throws {GhostAPIError} If the API request fails
|
|
80
|
+
*/
|
|
81
|
+
export async function getMember(params) {
|
|
82
|
+
const { id, email } = params;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
if (id) {
|
|
86
|
+
return await handleApiRequest('members', 'read', { id }, { id });
|
|
87
|
+
} else {
|
|
88
|
+
const sanitizedEmail = sanitizeNqlValue(email);
|
|
89
|
+
const members = await handleApiRequest(
|
|
90
|
+
'members',
|
|
91
|
+
'browse',
|
|
92
|
+
{},
|
|
93
|
+
{ filter: `email:'${sanitizedEmail}'`, limit: 1 }
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (!members || members.length === 0) {
|
|
97
|
+
throw new NotFoundError('Member', email);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return members[0];
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
104
|
+
throw new NotFoundError('Member', id || email);
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Search members by name or email
|
|
112
|
+
* @param {string} query - Search query (searches name and email fields)
|
|
113
|
+
* @param {Object} [options] - Additional options
|
|
114
|
+
* @param {number} [options.limit] - Maximum number of results (default: 15)
|
|
115
|
+
* @returns {Promise<Array>} Array of matching member objects
|
|
116
|
+
* @throws {ValidationError} If validation fails
|
|
117
|
+
* @throws {GhostAPIError} If the API request fails
|
|
118
|
+
*/
|
|
119
|
+
export async function searchMembers(query, options = {}) {
|
|
120
|
+
const sanitizedQuery = sanitizeNqlValue(query.trim());
|
|
121
|
+
const limit = options.limit || 15;
|
|
122
|
+
|
|
123
|
+
const filter = `name:~'${sanitizedQuery}',email:~'${sanitizedQuery}'`;
|
|
124
|
+
|
|
125
|
+
const members = await handleApiRequest('members', 'browse', {}, { filter, limit });
|
|
126
|
+
return members || [];
|
|
127
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createResourceService } from './createResourceService.js';
|
|
2
|
+
import { validators } from './validators.js';
|
|
3
|
+
|
|
4
|
+
const service = createResourceService({
|
|
5
|
+
resource: 'newsletters',
|
|
6
|
+
label: 'Newsletter',
|
|
7
|
+
listDefaults: { limit: 'all' },
|
|
8
|
+
validateCreate: (data) => validators.validateNewsletterData(data),
|
|
9
|
+
catch422OnUpdate: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lists all newsletters with optional filtering and pagination.
|
|
14
|
+
* @param {Object} [options={}] - Query options
|
|
15
|
+
* @param {string|number} [options.limit='all'] - Number of newsletters to return (default: 'all')
|
|
16
|
+
* @returns {Promise<Array>} Array of newsletter objects (empty array if none found)
|
|
17
|
+
* @throws {GhostAPIError} If the API request fails
|
|
18
|
+
*/
|
|
19
|
+
export const getNewsletters = service.getList;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Retrieves a single newsletter by ID.
|
|
23
|
+
* @param {string} newsletterId - The newsletter ID to retrieve
|
|
24
|
+
* @returns {Promise<Object>} The newsletter object
|
|
25
|
+
* @throws {ValidationError} If the newsletter ID is missing
|
|
26
|
+
* @throws {NotFoundError} If the newsletter is not found
|
|
27
|
+
* @throws {GhostAPIError} If the API request fails
|
|
28
|
+
*/
|
|
29
|
+
export const getNewsletter = service.getOne;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Creates a new newsletter in Ghost CMS.
|
|
33
|
+
* @param {Object} newsletterData - The newsletter data
|
|
34
|
+
* @param {string} newsletterData.name - Newsletter name (required)
|
|
35
|
+
* @param {string} [newsletterData.description] - Newsletter description
|
|
36
|
+
* @param {string} [newsletterData.sender_name] - Sender name
|
|
37
|
+
* @param {string} [newsletterData.sender_email] - Sender email
|
|
38
|
+
* @returns {Promise<Object>} The created newsletter object
|
|
39
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422
|
|
40
|
+
* @throws {GhostAPIError} If the API request fails
|
|
41
|
+
*/
|
|
42
|
+
export const createNewsletter = service.create;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Updates an existing newsletter with optimistic concurrency control.
|
|
46
|
+
* @param {string} newsletterId - The newsletter ID to update
|
|
47
|
+
* @param {Object} updateData - Fields to update on the newsletter
|
|
48
|
+
* @returns {Promise<Object>} The updated newsletter object
|
|
49
|
+
* @throws {ValidationError} If the newsletter ID is missing or Ghost returns a 422
|
|
50
|
+
* @throws {NotFoundError} If the newsletter is not found
|
|
51
|
+
* @throws {GhostAPIError} If the API request fails
|
|
52
|
+
*/
|
|
53
|
+
export const updateNewsletter = service.update;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Deletes a newsletter by ID.
|
|
57
|
+
* @param {string} newsletterId - The newsletter ID to delete
|
|
58
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
59
|
+
* @throws {ValidationError} If the newsletter ID is missing
|
|
60
|
+
* @throws {NotFoundError} If the newsletter is not found
|
|
61
|
+
* @throws {GhostAPIError} If the API request fails
|
|
62
|
+
*/
|
|
63
|
+
export const deleteNewsletter = service.remove;
|
|
@@ -41,8 +41,8 @@ const pageInputSchema = Joi.object({
|
|
|
41
41
|
published_at: Joi.string().isoDate().optional(),
|
|
42
42
|
// NO tags field - pages don't support tags
|
|
43
43
|
feature_image: Joi.string().uri().optional(),
|
|
44
|
-
feature_image_alt: Joi.string().max(
|
|
45
|
-
feature_image_caption: Joi.string().max(
|
|
44
|
+
feature_image_alt: Joi.string().max(191).optional(),
|
|
45
|
+
feature_image_caption: Joi.string().max(5000).optional(),
|
|
46
46
|
meta_title: Joi.string().max(70).optional(),
|
|
47
47
|
meta_description: Joi.string().max(160).optional(),
|
|
48
48
|
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { ValidationError } from '../errors/index.js';
|
|
2
|
+
import { sanitizeNqlValue } from '../utils/nqlSanitizer.js';
|
|
3
|
+
import { handleApiRequest, readResource } from './ghostApiClient.js';
|
|
4
|
+
import { createResourceService } from './createResourceService.js';
|
|
5
|
+
import { validators } from './validators.js';
|
|
6
|
+
|
|
7
|
+
const service = createResourceService({
|
|
8
|
+
resource: 'pages',
|
|
9
|
+
label: 'Page',
|
|
10
|
+
createDefaults: { status: 'draft' },
|
|
11
|
+
createOptions: { source: 'html' },
|
|
12
|
+
listDefaults: { limit: 15, include: 'authors' },
|
|
13
|
+
validateCreate: (data) => validators.validatePageData(data),
|
|
14
|
+
validateUpdate: async (id, updateData) => {
|
|
15
|
+
if (updateData.status || updateData.published_at) {
|
|
16
|
+
let validationData = updateData;
|
|
17
|
+
if (!updateData.status && updateData.published_at) {
|
|
18
|
+
const existing = await readResource('pages', id, 'Page');
|
|
19
|
+
validationData = { ...updateData, status: existing.status };
|
|
20
|
+
}
|
|
21
|
+
validators.validateScheduledStatus(validationData, 'Page');
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a new page in Ghost CMS.
|
|
28
|
+
* @param {Object} pageData - The page data
|
|
29
|
+
* @param {string} pageData.title - Page title (required)
|
|
30
|
+
* @param {string} [pageData.html] - HTML content (required if mobiledoc not provided)
|
|
31
|
+
* @param {string} [pageData.mobiledoc] - Mobiledoc content (required if html not provided)
|
|
32
|
+
* @param {string} [pageData.status='draft'] - Page status ('draft', 'published', 'scheduled')
|
|
33
|
+
* @param {string} [pageData.published_at] - ISO 8601 date for scheduled pages
|
|
34
|
+
* @param {Object} [options={ source: 'html' }] - API request options
|
|
35
|
+
* @returns {Promise<Object>} The created page object
|
|
36
|
+
* @throws {ValidationError} If validation fails or Ghost returns a 422
|
|
37
|
+
* @throws {GhostAPIError} If the API request fails
|
|
38
|
+
*/
|
|
39
|
+
export const createPage = service.create;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Updates an existing page with optimistic concurrency control.
|
|
43
|
+
* Validates scheduling fields when status or published_at is being changed.
|
|
44
|
+
* @param {string} pageId - The page ID to update
|
|
45
|
+
* @param {Object} updateData - Fields to update on the page
|
|
46
|
+
* @param {Object} [options={}] - API request options
|
|
47
|
+
* @returns {Promise<Object>} The updated page object
|
|
48
|
+
* @throws {ValidationError} If the page ID is missing or scheduling validation fails
|
|
49
|
+
* @throws {NotFoundError} If the page is not found
|
|
50
|
+
* @throws {GhostAPIError} If the API request fails
|
|
51
|
+
*/
|
|
52
|
+
export const updatePage = service.update;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Deletes a page by ID.
|
|
56
|
+
* @param {string} pageId - The page ID to delete
|
|
57
|
+
* @returns {Promise<Object>} Deletion confirmation
|
|
58
|
+
* @throws {ValidationError} If the page ID is missing
|
|
59
|
+
* @throws {NotFoundError} If the page is not found
|
|
60
|
+
* @throws {GhostAPIError} If the API request fails
|
|
61
|
+
*/
|
|
62
|
+
export const deletePage = service.remove;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Retrieves a single page by ID.
|
|
66
|
+
* @param {string} pageId - The page ID to retrieve
|
|
67
|
+
* @param {Object} [options={}] - API request options (e.g., includes)
|
|
68
|
+
* @returns {Promise<Object>} The page object
|
|
69
|
+
* @throws {ValidationError} If the page ID is missing
|
|
70
|
+
* @throws {NotFoundError} If the page is not found
|
|
71
|
+
* @throws {GhostAPIError} If the API request fails
|
|
72
|
+
*/
|
|
73
|
+
export const getPage = service.getOne;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Lists pages with optional filtering and pagination.
|
|
77
|
+
* @param {Object} [options={}] - Query options
|
|
78
|
+
* @param {number} [options.limit=15] - Number of pages to return
|
|
79
|
+
* @param {string} [options.include='authors'] - Related resources to include
|
|
80
|
+
* @param {string} [options.filter] - NQL filter string
|
|
81
|
+
* @param {string} [options.order] - Order string
|
|
82
|
+
* @returns {Promise<Array>} Array of page objects
|
|
83
|
+
* @throws {GhostAPIError} If the API request fails
|
|
84
|
+
*/
|
|
85
|
+
export const getPages = service.getList;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Searches pages by title using Ghost NQL fuzzy matching.
|
|
89
|
+
* @param {string} query - Search query string (required, non-empty)
|
|
90
|
+
* @param {Object} [options={}] - Additional search options
|
|
91
|
+
* @param {string} [options.status] - Filter by status ('draft', 'published', 'scheduled', or 'all')
|
|
92
|
+
* @param {number} [options.limit=15] - Maximum number of results
|
|
93
|
+
* @returns {Promise<Array>} Array of matching page objects
|
|
94
|
+
* @throws {ValidationError} If the query is empty
|
|
95
|
+
* @throws {GhostAPIError} If the API request fails
|
|
96
|
+
*/
|
|
97
|
+
export async function searchPages(query, options = {}) {
|
|
98
|
+
if (!query || query.trim().length === 0) {
|
|
99
|
+
throw new ValidationError('Search query is required');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sanitizedQuery = sanitizeNqlValue(query);
|
|
103
|
+
|
|
104
|
+
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
105
|
+
if (options.status && options.status !== 'all') {
|
|
106
|
+
filterParts.push(`status:${options.status}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const searchOptions = {
|
|
110
|
+
limit: options.limit || 15,
|
|
111
|
+
include: 'authors',
|
|
112
|
+
filter: filterParts.join('+'),
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return handleApiRequest('pages', 'browse', {}, searchOptions);
|
|
116
|
+
}
|