@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.
Files changed (40) hide show
  1. package/README.md +68 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/helpers/testUtils.js +15 -1
  4. package/src/__tests__/mcp_server.test.js +152 -1
  5. package/src/__tests__/mcp_server_pages.test.js +23 -6
  6. package/src/controllers/__tests__/imageController.test.js +2 -2
  7. package/src/controllers/imageController.js +11 -10
  8. package/src/mcp_server.js +647 -1203
  9. package/src/routes/__tests__/imageRoutes.test.js +2 -2
  10. package/src/schemas/__tests__/common.test.js +3 -3
  11. package/src/schemas/__tests__/pageSchemas.test.js +11 -2
  12. package/src/schemas/common.js +3 -2
  13. package/src/schemas/pageSchemas.js +1 -1
  14. package/src/schemas/postSchemas.js +1 -1
  15. package/src/services/__tests__/createResourceService.test.js +468 -0
  16. package/src/services/__tests__/ghostService.test.js +0 -19
  17. package/src/services/__tests__/ghostServiceImproved.members.test.js +0 -3
  18. package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +2 -2
  19. package/src/services/__tests__/ghostServiceImproved.pages.test.js +0 -3
  20. package/src/services/__tests__/ghostServiceImproved.posts.test.js +0 -1
  21. package/src/services/__tests__/ghostServiceImproved.tags.test.js +0 -3
  22. package/src/services/__tests__/ghostServiceImproved.tiers.test.js +3 -5
  23. package/src/services/__tests__/imageProcessingService.test.js +148 -177
  24. package/src/services/__tests__/images.test.js +78 -0
  25. package/src/services/createResourceService.js +138 -0
  26. package/src/services/ghostApiClient.js +240 -0
  27. package/src/services/ghostService.js +1 -19
  28. package/src/services/ghostServiceImproved.js +76 -915
  29. package/src/services/imageProcessingService.js +100 -56
  30. package/src/services/images.js +54 -0
  31. package/src/services/members.js +127 -0
  32. package/src/services/newsletters.js +63 -0
  33. package/src/services/pageService.js +2 -2
  34. package/src/services/pages.js +116 -0
  35. package/src/services/posts.js +116 -0
  36. package/src/services/tags.js +118 -0
  37. package/src/services/tiers.js +72 -0
  38. package/src/services/validators.js +218 -0
  39. package/src/utils/__tests__/imageInputResolver.test.js +134 -0
  40. 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 fs from 'fs';
4
- import Joi from 'joi';
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 OUTPUT_QUALITY = 80; // JPEG quality
8
+ const JPEG_QUALITY = 80;
10
9
 
11
- /**
12
- * Processes an image: resizes if too large, ensures JPEG format (configurable).
13
- * @param {string} inputPath - Path to the original uploaded image.
14
- * @param {string} outputDir - Directory to save the processed image.
15
- * @returns {Promise<string>} Path to the processed image.
16
- */
17
- // Validation schema for processing parameters
18
- const processImageSchema = Joi.object({
19
- inputPath: Joi.string().required(),
20
- outputDir: Joi.string().required(),
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 processImage = async (inputPath, outputDir) => {
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
- // Validate inputs to prevent path injection
27
- const { error } = processImageSchema.validate({ inputPath, outputDir });
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
- // Ensure paths are safe
38
- const resolvedInputPath = path.resolve(inputPath);
58
+ const resolvedInput = path.resolve(inputPath);
39
59
  const resolvedOutputDir = path.resolve(outputDir);
40
60
 
41
- // Verify input file exists
42
- if (!fs.existsSync(resolvedInputPath)) {
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 filename = path.basename(resolvedInputPath);
47
- const nameWithoutExt = filename.split('.').slice(0, -1).join('.');
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
- logger.info('Processing image', {
55
- inputFile: path.basename(inputPath),
56
- outputDir: path.basename(outputDir),
57
- });
58
- const image = sharp(inputPath);
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
- let processedImage = image;
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
- // Resize if wider than MAX_WIDTH
64
- if (metadata.width && metadata.width > MAX_WIDTH) {
65
- logger.info('Resizing image', {
66
- originalWidth: metadata.width,
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
- processedImage = processedImage.resize({ width: MAX_WIDTH });
95
+ return await passthrough(resolvedInput, resolvedOutputDir, baseName, timestamp, inputExt);
71
96
  }
72
97
 
73
- // Convert to JPEG with specified quality
74
- // You could add options for PNG/WebP etc. if needed
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
- logger.info('Image processing completed', {
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
- export { processImage };
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(255).optional(),
45
- feature_image_caption: Joi.string().max(500).optional(),
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
+ }