@jgardner04/ghost-mcp-server 1.13.2 → 1.13.3
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/package.json +5 -13
- package/src/__tests__/mcp_server.test.js +204 -117
- package/src/__tests__/mcp_server_pages.test.js +32 -18
- package/src/config/mcp-config.js +1 -1
- package/src/controllers/__tests__/tagController.test.js +12 -8
- package/src/controllers/tagController.js +2 -2
- package/src/errors/__tests__/index.test.js +3 -3
- package/src/errors/index.js +1 -1
- package/src/index.js +1 -1
- package/src/mcp_server.js +35 -31
- package/src/schemas/__tests__/postSchemas.test.js +19 -0
- package/src/schemas/__tests__/tagSchemas.test.js +1 -1
- package/src/schemas/common.js +2 -2
- package/src/schemas/memberSchemas.js +20 -8
- package/src/schemas/newsletterSchemas.js +10 -10
- package/src/schemas/pageSchemas.js +16 -11
- package/src/schemas/postSchemas.js +22 -15
- package/src/schemas/tagSchemas.js +12 -7
- package/src/schemas/tierSchemas.js +17 -8
- package/src/services/__tests__/ghostServiceImproved.members.test.js +7 -2
- package/src/services/__tests__/ghostServiceImproved.newsletters.test.js +10 -6
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +4 -4
- package/src/services/__tests__/ghostServiceImproved.posts.test.js +4 -4
- package/src/services/__tests__/ghostServiceImproved.tags.test.js +2 -2
- package/src/services/__tests__/ghostServiceImproved.tiers.test.js +9 -14
- package/src/services/__tests__/memberService.test.js +0 -28
- package/src/services/__tests__/tierService.test.js +0 -28
- package/src/services/ghostServiceImproved.js +69 -217
- package/src/services/imageProcessingService.js +1 -1
- package/src/services/memberService.js +0 -13
- package/src/services/tierService.js +0 -13
- package/src/utils/__tests__/nqlSanitizer.test.js +38 -0
- package/src/utils/nqlSanitizer.js +11 -0
|
@@ -175,7 +175,7 @@ describe('mcp_server - ghost_get_pages tool', () => {
|
|
|
175
175
|
const tool = mockTools.get('ghost_get_pages');
|
|
176
176
|
const result = await tool.handler({});
|
|
177
177
|
|
|
178
|
-
expect(mockGetPages).toHaveBeenCalledWith({});
|
|
178
|
+
expect(mockGetPages).toHaveBeenCalledWith(expect.objectContaining({}));
|
|
179
179
|
expect(result.content[0].text).toContain('About Us');
|
|
180
180
|
expect(result.content[0].text).toContain('Contact');
|
|
181
181
|
});
|
|
@@ -187,7 +187,7 @@ describe('mcp_server - ghost_get_pages tool', () => {
|
|
|
187
187
|
const tool = mockTools.get('ghost_get_pages');
|
|
188
188
|
await tool.handler({ limit: 10, page: 2 });
|
|
189
189
|
|
|
190
|
-
expect(mockGetPages).toHaveBeenCalledWith({ limit: 10, page: 2 });
|
|
190
|
+
expect(mockGetPages).toHaveBeenCalledWith(expect.objectContaining({ limit: 10, page: 2 }));
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
it('should validate limit is between 1 and 100', () => {
|
|
@@ -207,7 +207,9 @@ describe('mcp_server - ghost_get_pages tool', () => {
|
|
|
207
207
|
const tool = mockTools.get('ghost_get_pages');
|
|
208
208
|
await tool.handler({ filter: 'status:published' });
|
|
209
209
|
|
|
210
|
-
expect(mockGetPages).toHaveBeenCalledWith(
|
|
210
|
+
expect(mockGetPages).toHaveBeenCalledWith(
|
|
211
|
+
expect.objectContaining({ filter: 'status:published' })
|
|
212
|
+
);
|
|
211
213
|
});
|
|
212
214
|
|
|
213
215
|
it('should handle errors gracefully', async () => {
|
|
@@ -236,8 +238,8 @@ describe('mcp_server - ghost_get_page tool', () => {
|
|
|
236
238
|
it('should have correct schema with id and slug options', () => {
|
|
237
239
|
const tool = mockTools.get('ghost_get_page');
|
|
238
240
|
expect(tool).toBeDefined();
|
|
239
|
-
//
|
|
240
|
-
const shape = tool.schema.
|
|
241
|
+
// In Zod v4, refined schemas expose .shape directly
|
|
242
|
+
const shape = tool.schema.shape;
|
|
241
243
|
expect(shape.id).toBeDefined();
|
|
242
244
|
expect(shape.slug).toBeDefined();
|
|
243
245
|
expect(shape.include).toBeDefined();
|
|
@@ -250,7 +252,10 @@ describe('mcp_server - ghost_get_page tool', () => {
|
|
|
250
252
|
const tool = mockTools.get('ghost_get_page');
|
|
251
253
|
const result = await tool.handler({ id: '507f1f77bcf86cd799439011' });
|
|
252
254
|
|
|
253
|
-
expect(mockGetPage).toHaveBeenCalledWith(
|
|
255
|
+
expect(mockGetPage).toHaveBeenCalledWith(
|
|
256
|
+
'507f1f77bcf86cd799439011',
|
|
257
|
+
expect.objectContaining({})
|
|
258
|
+
);
|
|
254
259
|
expect(result.content[0].text).toContain('About Us');
|
|
255
260
|
});
|
|
256
261
|
|
|
@@ -261,7 +266,7 @@ describe('mcp_server - ghost_get_page tool', () => {
|
|
|
261
266
|
const tool = mockTools.get('ghost_get_page');
|
|
262
267
|
const result = await tool.handler({ slug: 'about-us' });
|
|
263
268
|
|
|
264
|
-
expect(mockGetPage).toHaveBeenCalledWith('slug/about-us', {});
|
|
269
|
+
expect(mockGetPage).toHaveBeenCalledWith('slug/about-us', expect.objectContaining({}));
|
|
265
270
|
expect(result.content[0].text).toContain('About Us');
|
|
266
271
|
});
|
|
267
272
|
|
|
@@ -389,9 +394,12 @@ describe('mcp_server - ghost_update_page tool', () => {
|
|
|
389
394
|
const tool = mockTools.get('ghost_update_page');
|
|
390
395
|
const result = await tool.handler({ id: '507f1f77bcf86cd799439011', title: 'Updated Title' });
|
|
391
396
|
|
|
392
|
-
expect(mockUpdatePage).toHaveBeenCalledWith(
|
|
393
|
-
|
|
394
|
-
|
|
397
|
+
expect(mockUpdatePage).toHaveBeenCalledWith(
|
|
398
|
+
'507f1f77bcf86cd799439011',
|
|
399
|
+
expect.objectContaining({
|
|
400
|
+
title: 'Updated Title',
|
|
401
|
+
})
|
|
402
|
+
);
|
|
395
403
|
expect(result.content[0].text).toContain('Updated Title');
|
|
396
404
|
});
|
|
397
405
|
|
|
@@ -407,11 +415,14 @@ describe('mcp_server - ghost_update_page tool', () => {
|
|
|
407
415
|
html: '<p>Updated content</p>',
|
|
408
416
|
});
|
|
409
417
|
|
|
410
|
-
expect(mockUpdatePage).toHaveBeenCalledWith(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
418
|
+
expect(mockUpdatePage).toHaveBeenCalledWith(
|
|
419
|
+
'507f1f77bcf86cd799439011',
|
|
420
|
+
expect.objectContaining({
|
|
421
|
+
title: 'New Title',
|
|
422
|
+
status: 'published',
|
|
423
|
+
html: '<p>Updated content</p>',
|
|
424
|
+
})
|
|
425
|
+
);
|
|
415
426
|
expect(result.content[0].text).toContain('New Title');
|
|
416
427
|
});
|
|
417
428
|
|
|
@@ -493,7 +504,7 @@ describe('mcp_server - ghost_search_pages tool', () => {
|
|
|
493
504
|
const tool = mockTools.get('ghost_search_pages');
|
|
494
505
|
const result = await tool.handler({ query: 'about' });
|
|
495
506
|
|
|
496
|
-
expect(mockSearchPages).toHaveBeenCalledWith('about', {});
|
|
507
|
+
expect(mockSearchPages).toHaveBeenCalledWith('about', expect.objectContaining({}));
|
|
497
508
|
expect(result.content[0].text).toContain('About Us');
|
|
498
509
|
});
|
|
499
510
|
|
|
@@ -504,7 +515,10 @@ describe('mcp_server - ghost_search_pages tool', () => {
|
|
|
504
515
|
const tool = mockTools.get('ghost_search_pages');
|
|
505
516
|
await tool.handler({ query: 'test', status: 'published' });
|
|
506
517
|
|
|
507
|
-
expect(mockSearchPages).toHaveBeenCalledWith(
|
|
518
|
+
expect(mockSearchPages).toHaveBeenCalledWith(
|
|
519
|
+
'test',
|
|
520
|
+
expect.objectContaining({ status: 'published' })
|
|
521
|
+
);
|
|
508
522
|
});
|
|
509
523
|
|
|
510
524
|
it('should pass limit option', async () => {
|
|
@@ -514,7 +528,7 @@ describe('mcp_server - ghost_search_pages tool', () => {
|
|
|
514
528
|
const tool = mockTools.get('ghost_search_pages');
|
|
515
529
|
await tool.handler({ query: 'test', limit: 5 });
|
|
516
530
|
|
|
517
|
-
expect(mockSearchPages).toHaveBeenCalledWith('test', { limit: 5 });
|
|
531
|
+
expect(mockSearchPages).toHaveBeenCalledWith('test', expect.objectContaining({ limit: 5 }));
|
|
518
532
|
});
|
|
519
533
|
|
|
520
534
|
it('should validate limit is between 1 and 50', () => {
|
package/src/config/mcp-config.js
CHANGED
|
@@ -44,7 +44,7 @@ describe('tagController', () => {
|
|
|
44
44
|
|
|
45
45
|
await getTags(req, res, next);
|
|
46
46
|
|
|
47
|
-
expect(ghostService.getTags).toHaveBeenCalledWith({});
|
|
47
|
+
expect(ghostService.getTags).toHaveBeenCalledWith(expect.objectContaining({}));
|
|
48
48
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
49
49
|
expect(res.json).toHaveBeenCalledWith(mockTags);
|
|
50
50
|
expect(next).not.toHaveBeenCalled();
|
|
@@ -60,7 +60,9 @@ describe('tagController', () => {
|
|
|
60
60
|
|
|
61
61
|
await getTags(req, res, next);
|
|
62
62
|
|
|
63
|
-
expect(ghostService.getTags).toHaveBeenCalledWith(
|
|
63
|
+
expect(ghostService.getTags).toHaveBeenCalledWith(
|
|
64
|
+
expect.objectContaining({ filter: "name:'Technology'" })
|
|
65
|
+
);
|
|
64
66
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
65
67
|
expect(res.json).toHaveBeenCalledWith(mockTags);
|
|
66
68
|
expect(next).not.toHaveBeenCalled();
|
|
@@ -76,7 +78,7 @@ describe('tagController', () => {
|
|
|
76
78
|
|
|
77
79
|
await getTags(req, res, next);
|
|
78
80
|
|
|
79
|
-
expect(ghostService.getTags).toHaveBeenCalledWith({});
|
|
81
|
+
expect(ghostService.getTags).toHaveBeenCalledWith(expect.objectContaining({}));
|
|
80
82
|
expect(res.status).not.toHaveBeenCalled();
|
|
81
83
|
expect(res.json).not.toHaveBeenCalled();
|
|
82
84
|
expect(next).toHaveBeenCalledWith(mockError);
|
|
@@ -121,11 +123,13 @@ describe('tagController', () => {
|
|
|
121
123
|
|
|
122
124
|
await getTags(req, res, next);
|
|
123
125
|
|
|
124
|
-
expect(ghostService.getTags).toHaveBeenCalledWith(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
expect(ghostService.getTags).toHaveBeenCalledWith(
|
|
127
|
+
expect.objectContaining({
|
|
128
|
+
limit: 10,
|
|
129
|
+
order: 'name asc',
|
|
130
|
+
include: 'count.posts',
|
|
131
|
+
})
|
|
132
|
+
);
|
|
129
133
|
expect(res.status).toHaveBeenCalledWith(200);
|
|
130
134
|
});
|
|
131
135
|
|
|
@@ -43,10 +43,10 @@ const getTags = async (req, res, next) => {
|
|
|
43
43
|
res.status(200).json(tags);
|
|
44
44
|
} catch (error) {
|
|
45
45
|
if (error instanceof ZodError) {
|
|
46
|
-
logger.warn('Invalid query parameters', { errors: error.
|
|
46
|
+
logger.warn('Invalid query parameters', { errors: error.issues });
|
|
47
47
|
return res.status(400).json({
|
|
48
48
|
message: 'Invalid query parameters',
|
|
49
|
-
errors: error.
|
|
49
|
+
errors: error.issues.map((e) => ({ path: e.path.join('.'), message: e.message })),
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -125,7 +125,7 @@ describe('Error Handling System', () => {
|
|
|
125
125
|
|
|
126
126
|
it('should create validation error from Zod error', () => {
|
|
127
127
|
const zodError = {
|
|
128
|
-
|
|
128
|
+
issues: [
|
|
129
129
|
{
|
|
130
130
|
path: ['user', 'email'],
|
|
131
131
|
message: 'Invalid email',
|
|
@@ -157,7 +157,7 @@ describe('Error Handling System', () => {
|
|
|
157
157
|
|
|
158
158
|
it('should create validation error from Zod error with context', () => {
|
|
159
159
|
const zodError = {
|
|
160
|
-
|
|
160
|
+
issues: [
|
|
161
161
|
{
|
|
162
162
|
path: ['name'],
|
|
163
163
|
message: 'String must contain at least 1 character(s)',
|
|
@@ -179,7 +179,7 @@ describe('Error Handling System', () => {
|
|
|
179
179
|
|
|
180
180
|
it('should create validation error from Zod error with empty path', () => {
|
|
181
181
|
const zodError = {
|
|
182
|
-
|
|
182
|
+
issues: [
|
|
183
183
|
{
|
|
184
184
|
path: [],
|
|
185
185
|
message: 'Invalid input',
|
package/src/errors/index.js
CHANGED
|
@@ -57,7 +57,7 @@ export class ValidationError extends BaseError {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
static fromZod(zodError, context = '') {
|
|
60
|
-
const errors = zodError.
|
|
60
|
+
const errors = zodError.issues.map((err) => ({
|
|
61
61
|
field: err.path.join('.'),
|
|
62
62
|
message: err.message,
|
|
63
63
|
type: err.code,
|
package/src/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import tagRoutes from './routes/tagRoutes.js'; // Import tag routes
|
|
|
8
8
|
import { createContextLogger } from './utils/logger.js';
|
|
9
9
|
|
|
10
10
|
// Load environment variables from .env file
|
|
11
|
-
dotenv.config();
|
|
11
|
+
dotenv.config({ quiet: true });
|
|
12
12
|
|
|
13
13
|
// Initialize logger for main server
|
|
14
14
|
const logger = createContextLogger('main');
|
package/src/mcp_server.js
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
} from './schemas/index.js';
|
|
36
36
|
|
|
37
37
|
// Load environment variables
|
|
38
|
-
dotenv.config();
|
|
38
|
+
dotenv.config({ quiet: true });
|
|
39
39
|
|
|
40
40
|
// Lazy-loaded modules (to avoid Node.js v25 Buffer compatibility issues at startup)
|
|
41
41
|
let ghostService = null;
|
|
@@ -95,12 +95,12 @@ const server = new McpServer({
|
|
|
95
95
|
const getTagsSchema = tagQueryBaseSchema.partial();
|
|
96
96
|
const getTagSchema = z
|
|
97
97
|
.object({
|
|
98
|
-
id: ghostIdSchema.optional().
|
|
99
|
-
slug: z.string().optional().
|
|
98
|
+
id: ghostIdSchema.optional().meta({ description: 'The ID of the tag to retrieve.' }),
|
|
99
|
+
slug: z.string().optional().meta({ description: 'The slug of the tag to retrieve.' }),
|
|
100
100
|
include: z
|
|
101
101
|
.string()
|
|
102
102
|
.optional()
|
|
103
|
-
.
|
|
103
|
+
.meta({ description: 'Additional resources to include (e.g., "count.posts").' }),
|
|
104
104
|
})
|
|
105
105
|
.refine((data) => data.id || data.slug, {
|
|
106
106
|
message: 'Either id or slug is required to retrieve a tag',
|
|
@@ -350,11 +350,11 @@ server.registerTool(
|
|
|
350
350
|
|
|
351
351
|
// --- Image Schema ---
|
|
352
352
|
const uploadImageSchema = z.object({
|
|
353
|
-
imageUrl: z.string().
|
|
354
|
-
alt: z
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
353
|
+
imageUrl: z.string().meta({ description: 'The publicly accessible URL of the image to upload.' }),
|
|
354
|
+
alt: z.string().optional().meta({
|
|
355
|
+
description:
|
|
356
|
+
'Alt text for the image. If omitted, a default will be generated from the filename.',
|
|
357
|
+
}),
|
|
358
358
|
});
|
|
359
359
|
|
|
360
360
|
// Upload Image Tool
|
|
@@ -449,33 +449,32 @@ const getPostsSchema = postQuerySchema.extend({
|
|
|
449
449
|
status: z
|
|
450
450
|
.enum(['published', 'draft', 'scheduled', 'all'])
|
|
451
451
|
.optional()
|
|
452
|
-
.
|
|
452
|
+
.meta({ description: 'Filter posts by status. Options: published, draft, scheduled, all.' }),
|
|
453
453
|
});
|
|
454
454
|
const getPostSchema = z
|
|
455
455
|
.object({
|
|
456
|
-
id: ghostIdSchema.optional().
|
|
457
|
-
slug: z.string().optional().
|
|
458
|
-
include: z
|
|
459
|
-
.
|
|
460
|
-
|
|
461
|
-
.describe('Comma-separated list of relations to include (e.g., "tags,authors").'),
|
|
456
|
+
id: ghostIdSchema.optional().meta({ description: 'The ID of the post to retrieve.' }),
|
|
457
|
+
slug: z.string().optional().meta({ description: 'The slug of the post to retrieve.' }),
|
|
458
|
+
include: z.string().optional().meta({
|
|
459
|
+
description: 'Comma-separated list of relations to include (e.g., "tags,authors").',
|
|
460
|
+
}),
|
|
462
461
|
})
|
|
463
462
|
.refine((data) => data.id || data.slug, {
|
|
464
463
|
message: 'Either id or slug is required to retrieve a post',
|
|
465
464
|
});
|
|
466
465
|
const searchPostsSchema = z.object({
|
|
467
|
-
query: z.string().min(1).
|
|
466
|
+
query: z.string().min(1).meta({ description: 'Search query to find in post titles.' }),
|
|
468
467
|
status: z
|
|
469
468
|
.enum(['published', 'draft', 'scheduled', 'all'])
|
|
470
469
|
.optional()
|
|
471
|
-
.
|
|
470
|
+
.meta({ description: 'Filter by post status. Default searches all statuses.' }),
|
|
472
471
|
limit: z
|
|
473
472
|
.number()
|
|
474
473
|
.int()
|
|
475
474
|
.min(1)
|
|
476
475
|
.max(50)
|
|
477
476
|
.optional()
|
|
478
|
-
.
|
|
477
|
+
.meta({ description: 'Maximum number of results (1-50). Default is 15.' }),
|
|
479
478
|
});
|
|
480
479
|
const updatePostInputSchema = updatePostSchema.extend({ id: ghostIdSchema });
|
|
481
480
|
const deletePostSchema = z.object({ id: ghostIdSchema });
|
|
@@ -762,29 +761,31 @@ server.registerTool(
|
|
|
762
761
|
// --- Page Schema Definitions ---
|
|
763
762
|
const getPageSchema = z
|
|
764
763
|
.object({
|
|
765
|
-
id: ghostIdSchema.optional().
|
|
766
|
-
slug: z.string().optional().
|
|
764
|
+
id: ghostIdSchema.optional().meta({ description: 'The ID of the page to retrieve.' }),
|
|
765
|
+
slug: z.string().optional().meta({ description: 'The slug of the page to retrieve.' }),
|
|
767
766
|
include: z
|
|
768
767
|
.string()
|
|
769
768
|
.optional()
|
|
770
|
-
.
|
|
769
|
+
.meta({ description: 'Comma-separated list of relations to include (e.g., "authors").' }),
|
|
771
770
|
})
|
|
772
771
|
.refine((data) => data.id || data.slug, {
|
|
773
772
|
message: 'Either id or slug is required to retrieve a page',
|
|
774
773
|
});
|
|
775
774
|
const updatePageInputSchema = z
|
|
776
|
-
.object({ id: ghostIdSchema.
|
|
775
|
+
.object({ id: ghostIdSchema.meta({ description: 'The ID of the page to update.' }) })
|
|
777
776
|
.merge(updatePageSchema);
|
|
778
|
-
const deletePageSchema = z.object({
|
|
777
|
+
const deletePageSchema = z.object({
|
|
778
|
+
id: ghostIdSchema.meta({ description: 'The ID of the page to delete.' }),
|
|
779
|
+
});
|
|
779
780
|
const searchPagesSchema = z.object({
|
|
780
781
|
query: z
|
|
781
782
|
.string()
|
|
782
783
|
.min(1, 'Search query cannot be empty')
|
|
783
|
-
.
|
|
784
|
+
.meta({ description: 'Search query to find in page titles.' }),
|
|
784
785
|
status: z
|
|
785
786
|
.enum(['published', 'draft', 'scheduled', 'all'])
|
|
786
787
|
.optional()
|
|
787
|
-
.
|
|
788
|
+
.meta({ description: 'Filter by page status. Default searches all statuses.' }),
|
|
788
789
|
limit: z
|
|
789
790
|
.number()
|
|
790
791
|
.int()
|
|
@@ -792,7 +793,7 @@ const searchPagesSchema = z.object({
|
|
|
792
793
|
.max(50)
|
|
793
794
|
.default(15)
|
|
794
795
|
.optional()
|
|
795
|
-
.
|
|
796
|
+
.meta({ description: 'Maximum number of results (1-50). Default is 15.' }),
|
|
796
797
|
});
|
|
797
798
|
|
|
798
799
|
// Get Pages Tool
|
|
@@ -1076,21 +1077,24 @@ const deleteMemberSchema = z.object({ id: ghostIdSchema });
|
|
|
1076
1077
|
const getMembersSchema = memberQuerySchema.omit({ search: true });
|
|
1077
1078
|
const getMemberSchema = z
|
|
1078
1079
|
.object({
|
|
1079
|
-
id: ghostIdSchema.optional().
|
|
1080
|
-
email: emailSchema.optional().
|
|
1080
|
+
id: ghostIdSchema.optional().meta({ description: 'The ID of the member to retrieve.' }),
|
|
1081
|
+
email: emailSchema.optional().meta({ description: 'The email of the member to retrieve.' }),
|
|
1081
1082
|
})
|
|
1082
1083
|
.refine((data) => data.id || data.email, {
|
|
1083
1084
|
message: 'Either id or email must be provided',
|
|
1084
1085
|
});
|
|
1085
1086
|
const searchMembersSchema = z.object({
|
|
1086
|
-
query: z
|
|
1087
|
+
query: z
|
|
1088
|
+
.string()
|
|
1089
|
+
.min(1)
|
|
1090
|
+
.meta({ description: 'Search query to match against member name or email.' }),
|
|
1087
1091
|
limit: z
|
|
1088
1092
|
.number()
|
|
1089
1093
|
.int()
|
|
1090
1094
|
.min(1)
|
|
1091
1095
|
.max(50)
|
|
1092
1096
|
.optional()
|
|
1093
|
-
.
|
|
1097
|
+
.meta({ description: 'Maximum number of results to return (1-50). Default is 15.' }),
|
|
1094
1098
|
});
|
|
1095
1099
|
|
|
1096
1100
|
// Create Member Tool
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
import {
|
|
3
4
|
createPostSchema,
|
|
4
5
|
updatePostSchema,
|
|
@@ -191,4 +192,22 @@ describe('Post Schemas', () => {
|
|
|
191
192
|
expect(() => postOutputSchema.parse(apiPost)).not.toThrow();
|
|
192
193
|
});
|
|
193
194
|
});
|
|
195
|
+
|
|
196
|
+
describe('schema metadata', () => {
|
|
197
|
+
it('should expose descriptions via .meta()', () => {
|
|
198
|
+
expect(createPostSchema.shape.html.meta()?.description).toBe('HTML content of the post');
|
|
199
|
+
expect(createPostSchema.shape.tags.meta()?.description).toContain(
|
|
200
|
+
'Array of tag names or IDs'
|
|
201
|
+
);
|
|
202
|
+
expect(createPostSchema.shape.email_only.meta()?.description).toBe(
|
|
203
|
+
'Whether post is email-only'
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should include descriptions in JSON Schema output', () => {
|
|
208
|
+
const jsonSchema = z.toJSONSchema(createPostSchema, { unrepresentable: 'any' });
|
|
209
|
+
expect(jsonSchema.properties.html.description).toBe('HTML content of the post');
|
|
210
|
+
expect(jsonSchema.properties.email_only.description).toBe('Whether post is email-only');
|
|
211
|
+
});
|
|
212
|
+
});
|
|
194
213
|
});
|
|
@@ -245,7 +245,7 @@ describe('Tag Schemas', () => {
|
|
|
245
245
|
try {
|
|
246
246
|
tagQuerySchema.parse(query);
|
|
247
247
|
} catch (error) {
|
|
248
|
-
expect(error.
|
|
248
|
+
expect(error.issues[0].message).toContain('Cannot specify both "name" and "filter"');
|
|
249
249
|
}
|
|
250
250
|
});
|
|
251
251
|
|
package/src/schemas/common.js
CHANGED
|
@@ -135,7 +135,7 @@ export const paginationSchema = z.object({
|
|
|
135
135
|
* Valid values: draft, published, scheduled
|
|
136
136
|
*/
|
|
137
137
|
export const postStatusSchema = z.enum(['draft', 'published', 'scheduled'], {
|
|
138
|
-
|
|
138
|
+
error: () => ({ message: 'Status must be draft, published, or scheduled' }),
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
/**
|
|
@@ -143,7 +143,7 @@ export const postStatusSchema = z.enum(['draft', 'published', 'scheduled'], {
|
|
|
143
143
|
* Controls content visibility (public, members, paid, tiers)
|
|
144
144
|
*/
|
|
145
145
|
export const visibilitySchema = z.enum(['public', 'members', 'paid', 'tiers'], {
|
|
146
|
-
|
|
146
|
+
error: () => ({ message: 'Visibility must be public, members, paid, or tiers' }),
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
// ----- Common Field Validators -----
|
|
@@ -17,13 +17,22 @@ export const createMemberSchema = z.object({
|
|
|
17
17
|
email: emailSchema,
|
|
18
18
|
name: z.string().max(191, 'Name cannot exceed 191 characters').optional(),
|
|
19
19
|
note: z.string().max(2000, 'Note cannot exceed 2000 characters').optional(),
|
|
20
|
-
subscribed: z
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
subscribed: z
|
|
21
|
+
.boolean()
|
|
22
|
+
.default(true)
|
|
23
|
+
.meta({ description: 'Whether member is subscribed to newsletter' }),
|
|
24
|
+
comped: z
|
|
25
|
+
.boolean()
|
|
26
|
+
.default(false)
|
|
27
|
+
.meta({ description: 'Whether member has complimentary subscription' }),
|
|
28
|
+
labels: z
|
|
29
|
+
.array(z.string())
|
|
30
|
+
.optional()
|
|
31
|
+
.meta({ description: 'Array of label names to associate with member' }),
|
|
23
32
|
newsletters: z
|
|
24
33
|
.array(ghostIdSchema)
|
|
25
34
|
.optional()
|
|
26
|
-
.
|
|
35
|
+
.meta({ description: 'Array of newsletter IDs to subscribe member to' }),
|
|
27
36
|
});
|
|
28
37
|
|
|
29
38
|
/**
|
|
@@ -42,13 +51,16 @@ export const memberQuerySchema = z.object({
|
|
|
42
51
|
.string()
|
|
43
52
|
.regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
|
|
44
53
|
.optional()
|
|
45
|
-
.
|
|
54
|
+
.meta({ description: 'NQL filter string (e.g., "status:paid+subscribed:true")' }),
|
|
46
55
|
include: z
|
|
47
56
|
.string()
|
|
48
57
|
.optional()
|
|
49
|
-
.
|
|
50
|
-
order: z
|
|
51
|
-
|
|
58
|
+
.meta({ description: 'Comma-separated list of relations (e.g., "labels,newsletters")' }),
|
|
59
|
+
order: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.meta({ description: 'Order results (e.g., "created_at DESC", "name ASC")' }),
|
|
63
|
+
search: z.string().optional().meta({ description: 'Search members by name or email' }),
|
|
52
64
|
});
|
|
53
65
|
|
|
54
66
|
/**
|
|
@@ -21,46 +21,46 @@ export const createNewsletterSchema = z.object({
|
|
|
21
21
|
sender_email: emailSchema.optional(),
|
|
22
22
|
sender_reply_to: z
|
|
23
23
|
.enum(['newsletter', 'support'], {
|
|
24
|
-
|
|
24
|
+
error: () => ({ message: 'Sender reply-to must be newsletter or support' }),
|
|
25
25
|
})
|
|
26
26
|
.default('newsletter'),
|
|
27
27
|
status: z
|
|
28
28
|
.enum(['active', 'archived'], {
|
|
29
|
-
|
|
29
|
+
error: () => ({ message: 'Status must be active or archived' }),
|
|
30
30
|
})
|
|
31
31
|
.default('active'),
|
|
32
32
|
visibility: z
|
|
33
33
|
.enum(['members', 'paid'], {
|
|
34
|
-
|
|
34
|
+
error: () => ({ message: 'Visibility must be members or paid' }),
|
|
35
35
|
})
|
|
36
36
|
.default('members'),
|
|
37
37
|
subscribe_on_signup: z
|
|
38
38
|
.boolean()
|
|
39
39
|
.default(true)
|
|
40
|
-
.
|
|
40
|
+
.meta({ description: 'Whether new members are automatically subscribed' }),
|
|
41
41
|
sort_order: z
|
|
42
42
|
.number()
|
|
43
43
|
.int()
|
|
44
44
|
.min(0, 'Sort order must be non-negative')
|
|
45
45
|
.optional()
|
|
46
|
-
.
|
|
46
|
+
.meta({ description: 'Display order for newsletters' }),
|
|
47
47
|
header_image: z.string().url('Invalid header image URL').optional(),
|
|
48
48
|
show_header_icon: z.boolean().default(true),
|
|
49
49
|
show_header_title: z.boolean().default(true),
|
|
50
50
|
title_font_category: z
|
|
51
51
|
.enum(['serif', 'sans-serif'], {
|
|
52
|
-
|
|
52
|
+
error: () => ({ message: 'Title font category must be serif or sans-serif' }),
|
|
53
53
|
})
|
|
54
54
|
.default('sans-serif'),
|
|
55
55
|
title_alignment: z
|
|
56
56
|
.enum(['left', 'center'], {
|
|
57
|
-
|
|
57
|
+
error: () => ({ message: 'Title alignment must be left or center' }),
|
|
58
58
|
})
|
|
59
59
|
.default('center'),
|
|
60
60
|
show_feature_image: z.boolean().default(true),
|
|
61
61
|
body_font_category: z
|
|
62
62
|
.enum(['serif', 'sans-serif'], {
|
|
63
|
-
|
|
63
|
+
error: () => ({ message: 'Body font category must be serif or sans-serif' }),
|
|
64
64
|
})
|
|
65
65
|
.default('sans-serif'),
|
|
66
66
|
footer_content: z.string().optional(),
|
|
@@ -85,11 +85,11 @@ export const newsletterQuerySchema = z.object({
|
|
|
85
85
|
.string()
|
|
86
86
|
.regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
|
|
87
87
|
.optional()
|
|
88
|
-
.
|
|
88
|
+
.meta({ description: 'NQL filter string (e.g., "status:active")' }),
|
|
89
89
|
order: z
|
|
90
90
|
.string()
|
|
91
91
|
.optional()
|
|
92
|
-
.
|
|
92
|
+
.meta({ description: 'Order results (e.g., "sort_order ASC", "created_at DESC")' }),
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
/**
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
*/
|
|
38
38
|
export const createPageSchema = z.object({
|
|
39
39
|
title: titleSchema,
|
|
40
|
-
html: htmlContentSchema.
|
|
40
|
+
html: htmlContentSchema.meta({ description: 'HTML content of the page' }),
|
|
41
41
|
slug: slugSchema.optional(),
|
|
42
42
|
status: postStatusSchema.default('draft'),
|
|
43
43
|
visibility: visibilitySchema.default('public'),
|
|
@@ -59,16 +59,18 @@ export const createPageSchema = z.object({
|
|
|
59
59
|
.max(500, 'Twitter description cannot exceed 500 characters')
|
|
60
60
|
.optional(),
|
|
61
61
|
canonical_url: canonicalUrlSchema,
|
|
62
|
-
tags: tagsSchema.
|
|
63
|
-
authors: authorsSchema.
|
|
64
|
-
published_at: isoDateSchema
|
|
62
|
+
tags: tagsSchema.meta({ description: 'Array of tag names or IDs (rarely used for pages)' }),
|
|
63
|
+
authors: authorsSchema.meta({ description: 'Array of author IDs or emails' }),
|
|
64
|
+
published_at: isoDateSchema
|
|
65
|
+
.optional()
|
|
66
|
+
.meta({ description: 'Scheduled publish time (ISO 8601 format)' }),
|
|
65
67
|
codeinjection_head: z.string().optional(),
|
|
66
68
|
codeinjection_foot: z.string().optional(),
|
|
67
|
-
custom_template: z.string().optional().
|
|
69
|
+
custom_template: z.string().optional().meta({ description: 'Custom template filename' }),
|
|
68
70
|
show_title_and_feature_image: z
|
|
69
71
|
.boolean()
|
|
70
72
|
.default(true)
|
|
71
|
-
.
|
|
73
|
+
.meta({ description: 'Whether to show title and feature image on page' }),
|
|
72
74
|
});
|
|
73
75
|
|
|
74
76
|
/**
|
|
@@ -87,17 +89,20 @@ export const pageQuerySchema = z.object({
|
|
|
87
89
|
.string()
|
|
88
90
|
.regex(/^[a-zA-Z0-9_\-:.'"\s,[\]<>=!+]+$/, 'Invalid filter: contains disallowed characters')
|
|
89
91
|
.optional()
|
|
90
|
-
.
|
|
92
|
+
.meta({ description: 'NQL filter string (e.g., "status:published+featured:true")' }),
|
|
91
93
|
include: z
|
|
92
94
|
.string()
|
|
93
95
|
.optional()
|
|
94
|
-
.
|
|
95
|
-
fields: z.string().optional().
|
|
96
|
+
.meta({ description: 'Comma-separated list of relations (e.g., "tags,authors")' }),
|
|
97
|
+
fields: z.string().optional().meta({ description: 'Comma-separated list of fields to return' }),
|
|
96
98
|
formats: z
|
|
97
99
|
.string()
|
|
98
100
|
.optional()
|
|
99
|
-
.
|
|
100
|
-
order: z
|
|
101
|
+
.meta({ description: 'Comma-separated list of formats (html, plaintext, mobiledoc)' }),
|
|
102
|
+
order: z
|
|
103
|
+
.string()
|
|
104
|
+
.optional()
|
|
105
|
+
.meta({ description: 'Order results (e.g., "published_at DESC", "title ASC")' }),
|
|
101
106
|
});
|
|
102
107
|
|
|
103
108
|
/**
|