@jgardner04/ghost-mcp-server 1.3.0 → 1.5.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_improved.test.js +153 -0
- package/src/__tests__/mcp_server_pages.test.js +520 -0
- package/src/mcp_server_improved.js +319 -1
- package/src/services/__tests__/ghostServiceImproved.pages.test.js +561 -0
- package/src/services/__tests__/pageService.test.js +400 -0
- package/src/services/ghostServiceImproved.js +289 -0
- package/src/services/pageService.js +121 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
3
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
4
|
+
|
|
5
|
+
// Mock dotenv
|
|
6
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
7
|
+
|
|
8
|
+
// Mock logger
|
|
9
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
10
|
+
createContextLogger: createMockContextLogger(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock ghostServiceImproved functions
|
|
14
|
+
vi.mock('../ghostServiceImproved.js', () => ({
|
|
15
|
+
createPage: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Import after mocks are set up
|
|
19
|
+
import { createPageService } from '../pageService.js';
|
|
20
|
+
import { createPage } from '../ghostServiceImproved.js';
|
|
21
|
+
|
|
22
|
+
describe('pageService', () => {
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
vi.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('createPageService - validation', () => {
|
|
28
|
+
it('should accept valid input and create a page', async () => {
|
|
29
|
+
const validInput = {
|
|
30
|
+
title: 'Test Page',
|
|
31
|
+
html: '<p>Test content</p>',
|
|
32
|
+
};
|
|
33
|
+
const expectedPage = { id: '1', title: 'Test Page', status: 'draft' };
|
|
34
|
+
createPage.mockResolvedValue(expectedPage);
|
|
35
|
+
|
|
36
|
+
const result = await createPageService(validInput);
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual(expectedPage);
|
|
39
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
40
|
+
expect.objectContaining({
|
|
41
|
+
title: 'Test Page',
|
|
42
|
+
html: '<p>Test content</p>',
|
|
43
|
+
status: 'draft',
|
|
44
|
+
})
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should reject input with missing title', async () => {
|
|
49
|
+
const invalidInput = {
|
|
50
|
+
html: '<p>Test content</p>',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
await expect(createPageService(invalidInput)).rejects.toThrow(
|
|
54
|
+
'Invalid page input: "title" is required'
|
|
55
|
+
);
|
|
56
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should reject input with missing html', async () => {
|
|
60
|
+
const invalidInput = {
|
|
61
|
+
title: 'Test Page',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
await expect(createPageService(invalidInput)).rejects.toThrow(
|
|
65
|
+
'Invalid page input: "html" is required'
|
|
66
|
+
);
|
|
67
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should reject input with invalid status', async () => {
|
|
71
|
+
const invalidInput = {
|
|
72
|
+
title: 'Test Page',
|
|
73
|
+
html: '<p>Content</p>',
|
|
74
|
+
status: 'invalid-status',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
await expect(createPageService(invalidInput)).rejects.toThrow(
|
|
78
|
+
'Invalid page input: "status" must be one of [draft, published, scheduled]'
|
|
79
|
+
);
|
|
80
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should accept valid status values', async () => {
|
|
84
|
+
const statuses = ['draft', 'published', 'scheduled'];
|
|
85
|
+
createPage.mockResolvedValue({ id: '1', title: 'Test' });
|
|
86
|
+
|
|
87
|
+
for (const status of statuses) {
|
|
88
|
+
const input = {
|
|
89
|
+
title: 'Test Page',
|
|
90
|
+
html: '<p>Content</p>',
|
|
91
|
+
status,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
await createPageService(input);
|
|
95
|
+
|
|
96
|
+
expect(createPage).toHaveBeenCalledWith(expect.objectContaining({ status }));
|
|
97
|
+
vi.clearAllMocks();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should reject tags field (pages do not support tags)', async () => {
|
|
102
|
+
const invalidInput = {
|
|
103
|
+
title: 'Test Page',
|
|
104
|
+
html: '<p>Content</p>',
|
|
105
|
+
tags: ['tag1', 'tag2'],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Tags field should cause validation error since it's not in the schema
|
|
109
|
+
await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
|
|
110
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should validate feature_image is a valid URI', async () => {
|
|
114
|
+
const invalidInput = {
|
|
115
|
+
title: 'Test Page',
|
|
116
|
+
html: '<p>Content</p>',
|
|
117
|
+
feature_image: 'not-a-valid-url',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
|
|
121
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should accept valid feature_image URI', async () => {
|
|
125
|
+
const validInput = {
|
|
126
|
+
title: 'Test Page',
|
|
127
|
+
html: '<p>Content</p>',
|
|
128
|
+
feature_image: 'https://example.com/image.jpg',
|
|
129
|
+
};
|
|
130
|
+
createPage.mockResolvedValue({ id: '1', title: 'Test' });
|
|
131
|
+
|
|
132
|
+
await createPageService(validInput);
|
|
133
|
+
|
|
134
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
feature_image: 'https://example.com/image.jpg',
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should validate title max length', async () => {
|
|
142
|
+
const invalidInput = {
|
|
143
|
+
title: 'a'.repeat(256), // 256 chars exceeds max of 255
|
|
144
|
+
html: '<p>Content</p>',
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
|
|
148
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should validate custom_excerpt max length', async () => {
|
|
152
|
+
const invalidInput = {
|
|
153
|
+
title: 'Test Page',
|
|
154
|
+
html: '<p>Content</p>',
|
|
155
|
+
custom_excerpt: 'a'.repeat(501), // 501 chars exceeds max of 500
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
|
|
159
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should validate meta_title max length', async () => {
|
|
163
|
+
const invalidInput = {
|
|
164
|
+
title: 'Test Page',
|
|
165
|
+
html: '<p>Content</p>',
|
|
166
|
+
meta_title: 'a'.repeat(71), // 71 chars exceeds max of 70
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
|
|
170
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should validate meta_description max length', async () => {
|
|
174
|
+
const invalidInput = {
|
|
175
|
+
title: 'Test Page',
|
|
176
|
+
html: '<p>Content</p>',
|
|
177
|
+
meta_description: 'a'.repeat(161), // 161 chars exceeds max of 160
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
|
|
181
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('should validate published_at is ISO date format', async () => {
|
|
185
|
+
const invalidInput = {
|
|
186
|
+
title: 'Test Page',
|
|
187
|
+
html: '<p>Content</p>',
|
|
188
|
+
published_at: 'invalid-date',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
await expect(createPageService(invalidInput)).rejects.toThrow('Invalid page input:');
|
|
192
|
+
expect(createPage).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should accept valid published_at ISO date', async () => {
|
|
196
|
+
const validInput = {
|
|
197
|
+
title: 'Test Page',
|
|
198
|
+
html: '<p>Content</p>',
|
|
199
|
+
published_at: '2024-12-31T12:00:00.000Z',
|
|
200
|
+
};
|
|
201
|
+
createPage.mockResolvedValue({ id: '1', title: 'Test' });
|
|
202
|
+
|
|
203
|
+
await createPageService(validInput);
|
|
204
|
+
|
|
205
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
206
|
+
expect.objectContaining({
|
|
207
|
+
published_at: '2024-12-31T12:00:00.000Z',
|
|
208
|
+
})
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('createPageService - metadata generation', () => {
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
createPage.mockResolvedValue({ id: '1', title: 'Test' });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should default meta_title to title when not provided', async () => {
|
|
219
|
+
const input = {
|
|
220
|
+
title: 'My Page Title',
|
|
221
|
+
html: '<p>Content</p>',
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
await createPageService(input);
|
|
225
|
+
|
|
226
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
227
|
+
expect.objectContaining({
|
|
228
|
+
meta_title: 'My Page Title',
|
|
229
|
+
})
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should use provided meta_title instead of defaulting', async () => {
|
|
234
|
+
const input = {
|
|
235
|
+
title: 'My Page Title',
|
|
236
|
+
html: '<p>Content</p>',
|
|
237
|
+
meta_title: 'Custom Meta Title',
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
await createPageService(input);
|
|
241
|
+
|
|
242
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
243
|
+
expect.objectContaining({
|
|
244
|
+
meta_title: 'Custom Meta Title',
|
|
245
|
+
})
|
|
246
|
+
);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('should default meta_description to custom_excerpt when provided', async () => {
|
|
250
|
+
const input = {
|
|
251
|
+
title: 'Test Page',
|
|
252
|
+
html: '<p>Content</p>',
|
|
253
|
+
custom_excerpt: 'This is my custom excerpt',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
await createPageService(input);
|
|
257
|
+
|
|
258
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
259
|
+
expect.objectContaining({
|
|
260
|
+
meta_description: 'This is my custom excerpt',
|
|
261
|
+
})
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should generate meta_description from HTML when not provided', async () => {
|
|
266
|
+
const input = {
|
|
267
|
+
title: 'Test Page',
|
|
268
|
+
html: '<p>This is the page content that will be used for meta description.</p>',
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
await createPageService(input);
|
|
272
|
+
|
|
273
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
274
|
+
expect.objectContaining({
|
|
275
|
+
meta_description: expect.stringContaining('This is the page content'),
|
|
276
|
+
})
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should use provided meta_description over custom_excerpt', async () => {
|
|
281
|
+
const input = {
|
|
282
|
+
title: 'Test Page',
|
|
283
|
+
html: '<p>Content</p>',
|
|
284
|
+
custom_excerpt: 'This is the excerpt',
|
|
285
|
+
meta_description: 'This is the explicit meta description',
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
await createPageService(input);
|
|
289
|
+
|
|
290
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
291
|
+
expect.objectContaining({
|
|
292
|
+
meta_description: 'This is the explicit meta description',
|
|
293
|
+
})
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('should strip HTML tags when generating meta_description', async () => {
|
|
298
|
+
const input = {
|
|
299
|
+
title: 'Test Page',
|
|
300
|
+
html: '<h1>Heading</h1><p><strong>Bold</strong> and <em>italic</em> text</p>',
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
await createPageService(input);
|
|
304
|
+
|
|
305
|
+
const calledWith = createPage.mock.calls[0][0];
|
|
306
|
+
expect(calledWith.meta_description).not.toContain('<');
|
|
307
|
+
expect(calledWith.meta_description).not.toContain('>');
|
|
308
|
+
expect(calledWith.meta_description).toContain('Heading');
|
|
309
|
+
expect(calledWith.meta_description).toContain('Bold');
|
|
310
|
+
expect(calledWith.meta_description).toContain('italic');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('should truncate meta_description to 500 characters', async () => {
|
|
314
|
+
const longContent = 'a'.repeat(600);
|
|
315
|
+
const input = {
|
|
316
|
+
title: 'Test Page',
|
|
317
|
+
html: `<p>${longContent}</p>`,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
await createPageService(input);
|
|
321
|
+
|
|
322
|
+
const calledWith = createPage.mock.calls[0][0];
|
|
323
|
+
expect(calledWith.meta_description.length).toBeLessThanOrEqual(500);
|
|
324
|
+
expect(calledWith.meta_description).toContain('...');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should handle empty HTML content gracefully', async () => {
|
|
328
|
+
const input = {
|
|
329
|
+
title: 'Test Page',
|
|
330
|
+
html: '',
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Empty html should fail validation
|
|
334
|
+
await expect(createPageService(input)).rejects.toThrow('Invalid page input:');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('createPageService - complete page creation', () => {
|
|
339
|
+
it('should create page with all optional fields', async () => {
|
|
340
|
+
const fullInput = {
|
|
341
|
+
title: 'Complete Page',
|
|
342
|
+
html: '<p>Full content</p>',
|
|
343
|
+
custom_excerpt: 'Page excerpt',
|
|
344
|
+
status: 'published',
|
|
345
|
+
published_at: '2024-12-31T12:00:00.000Z',
|
|
346
|
+
feature_image: 'https://example.com/image.jpg',
|
|
347
|
+
feature_image_alt: 'Alt text',
|
|
348
|
+
feature_image_caption: 'Image caption',
|
|
349
|
+
meta_title: 'SEO Title',
|
|
350
|
+
meta_description: 'SEO Description',
|
|
351
|
+
};
|
|
352
|
+
const expectedPage = { id: '1', ...fullInput };
|
|
353
|
+
createPage.mockResolvedValue(expectedPage);
|
|
354
|
+
|
|
355
|
+
const result = await createPageService(fullInput);
|
|
356
|
+
|
|
357
|
+
expect(result).toEqual(expectedPage);
|
|
358
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
359
|
+
expect.objectContaining({
|
|
360
|
+
title: 'Complete Page',
|
|
361
|
+
html: '<p>Full content</p>',
|
|
362
|
+
custom_excerpt: 'Page excerpt',
|
|
363
|
+
status: 'published',
|
|
364
|
+
published_at: '2024-12-31T12:00:00.000Z',
|
|
365
|
+
feature_image: 'https://example.com/image.jpg',
|
|
366
|
+
feature_image_alt: 'Alt text',
|
|
367
|
+
feature_image_caption: 'Image caption',
|
|
368
|
+
meta_title: 'SEO Title',
|
|
369
|
+
meta_description: 'SEO Description',
|
|
370
|
+
})
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should default status to draft when not provided', async () => {
|
|
375
|
+
const input = {
|
|
376
|
+
title: 'Test Page',
|
|
377
|
+
html: '<p>Content</p>',
|
|
378
|
+
};
|
|
379
|
+
createPage.mockResolvedValue({ id: '1', title: 'Test', status: 'draft' });
|
|
380
|
+
|
|
381
|
+
await createPageService(input);
|
|
382
|
+
|
|
383
|
+
expect(createPage).toHaveBeenCalledWith(
|
|
384
|
+
expect.objectContaining({
|
|
385
|
+
status: 'draft',
|
|
386
|
+
})
|
|
387
|
+
);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should propagate errors from ghostServiceImproved', async () => {
|
|
391
|
+
const input = {
|
|
392
|
+
title: 'Test Page',
|
|
393
|
+
html: '<p>Content</p>',
|
|
394
|
+
};
|
|
395
|
+
createPage.mockRejectedValue(new Error('Ghost API error'));
|
|
396
|
+
|
|
397
|
+
await expect(createPageService(input)).rejects.toThrow('Ghost API error');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
});
|
|
@@ -185,6 +185,45 @@ 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
|
+
},
|
|
188
227
|
};
|
|
189
228
|
|
|
190
229
|
/**
|
|
@@ -336,6 +375,249 @@ export async function getPosts(options = {}) {
|
|
|
336
375
|
}
|
|
337
376
|
}
|
|
338
377
|
|
|
378
|
+
export async function searchPosts(query, options = {}) {
|
|
379
|
+
// Validate query
|
|
380
|
+
if (!query || query.trim().length === 0) {
|
|
381
|
+
throw new ValidationError('Search query is required');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Sanitize query - escape special NQL characters to prevent injection
|
|
385
|
+
const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
386
|
+
|
|
387
|
+
// Build filter with fuzzy title match using Ghost NQL
|
|
388
|
+
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
389
|
+
|
|
390
|
+
// Add status filter if provided and not 'all'
|
|
391
|
+
if (options.status && options.status !== 'all') {
|
|
392
|
+
filterParts.push(`status:${options.status}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const searchOptions = {
|
|
396
|
+
limit: options.limit || 15,
|
|
397
|
+
include: 'tags,authors',
|
|
398
|
+
filter: filterParts.join('+'),
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
return await handleApiRequest('posts', 'browse', {}, searchOptions);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.error('Failed to search posts:', error);
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Page CRUD Operations
|
|
411
|
+
* Pages are similar to posts but do NOT support tags
|
|
412
|
+
*/
|
|
413
|
+
|
|
414
|
+
export async function createPage(pageData, options = { source: 'html' }) {
|
|
415
|
+
// Validate input
|
|
416
|
+
validators.validatePageData(pageData);
|
|
417
|
+
|
|
418
|
+
// Add defaults
|
|
419
|
+
const dataWithDefaults = {
|
|
420
|
+
status: 'draft',
|
|
421
|
+
...pageData,
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// Sanitize HTML content if provided (use same sanitization as posts)
|
|
425
|
+
if (dataWithDefaults.html) {
|
|
426
|
+
dataWithDefaults.html = sanitizeHtml(dataWithDefaults.html, {
|
|
427
|
+
allowedTags: [
|
|
428
|
+
'h1',
|
|
429
|
+
'h2',
|
|
430
|
+
'h3',
|
|
431
|
+
'h4',
|
|
432
|
+
'h5',
|
|
433
|
+
'h6',
|
|
434
|
+
'blockquote',
|
|
435
|
+
'p',
|
|
436
|
+
'a',
|
|
437
|
+
'ul',
|
|
438
|
+
'ol',
|
|
439
|
+
'nl',
|
|
440
|
+
'li',
|
|
441
|
+
'b',
|
|
442
|
+
'i',
|
|
443
|
+
'strong',
|
|
444
|
+
'em',
|
|
445
|
+
'strike',
|
|
446
|
+
'code',
|
|
447
|
+
'hr',
|
|
448
|
+
'br',
|
|
449
|
+
'div',
|
|
450
|
+
'span',
|
|
451
|
+
'img',
|
|
452
|
+
'pre',
|
|
453
|
+
],
|
|
454
|
+
allowedAttributes: {
|
|
455
|
+
a: ['href', 'title'],
|
|
456
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
457
|
+
'*': ['class', 'id'],
|
|
458
|
+
},
|
|
459
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
460
|
+
allowedSchemesByTag: {
|
|
461
|
+
img: ['http', 'https', 'data'],
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
return await handleApiRequest('pages', 'add', dataWithDefaults, options);
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 422) {
|
|
470
|
+
throw new ValidationError('Page creation failed due to validation errors', [
|
|
471
|
+
{ field: 'page', message: error.originalError },
|
|
472
|
+
]);
|
|
473
|
+
}
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export async function updatePage(pageId, updateData, options = {}) {
|
|
479
|
+
if (!pageId) {
|
|
480
|
+
throw new ValidationError('Page ID is required for update');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Sanitize HTML if being updated
|
|
484
|
+
if (updateData.html) {
|
|
485
|
+
updateData.html = sanitizeHtml(updateData.html, {
|
|
486
|
+
allowedTags: [
|
|
487
|
+
'h1',
|
|
488
|
+
'h2',
|
|
489
|
+
'h3',
|
|
490
|
+
'h4',
|
|
491
|
+
'h5',
|
|
492
|
+
'h6',
|
|
493
|
+
'blockquote',
|
|
494
|
+
'p',
|
|
495
|
+
'a',
|
|
496
|
+
'ul',
|
|
497
|
+
'ol',
|
|
498
|
+
'nl',
|
|
499
|
+
'li',
|
|
500
|
+
'b',
|
|
501
|
+
'i',
|
|
502
|
+
'strong',
|
|
503
|
+
'em',
|
|
504
|
+
'strike',
|
|
505
|
+
'code',
|
|
506
|
+
'hr',
|
|
507
|
+
'br',
|
|
508
|
+
'div',
|
|
509
|
+
'span',
|
|
510
|
+
'img',
|
|
511
|
+
'pre',
|
|
512
|
+
],
|
|
513
|
+
allowedAttributes: {
|
|
514
|
+
a: ['href', 'title'],
|
|
515
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
516
|
+
'*': ['class', 'id'],
|
|
517
|
+
},
|
|
518
|
+
allowedSchemes: ['http', 'https', 'mailto'],
|
|
519
|
+
allowedSchemesByTag: {
|
|
520
|
+
img: ['http', 'https', 'data'],
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
// Get existing page to retrieve updated_at for conflict resolution
|
|
527
|
+
const existingPage = await handleApiRequest('pages', 'read', { id: pageId });
|
|
528
|
+
|
|
529
|
+
// Merge existing data with updates, preserving updated_at
|
|
530
|
+
const mergedData = {
|
|
531
|
+
...existingPage,
|
|
532
|
+
...updateData,
|
|
533
|
+
updated_at: existingPage.updated_at,
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
return await handleApiRequest('pages', 'edit', mergedData, { id: pageId, ...options });
|
|
537
|
+
} catch (error) {
|
|
538
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
539
|
+
throw new NotFoundError('Page', pageId);
|
|
540
|
+
}
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export async function deletePage(pageId) {
|
|
546
|
+
if (!pageId) {
|
|
547
|
+
throw new ValidationError('Page ID is required for delete');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
return await handleApiRequest('pages', 'delete', { id: pageId });
|
|
552
|
+
} catch (error) {
|
|
553
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
554
|
+
throw new NotFoundError('Page', pageId);
|
|
555
|
+
}
|
|
556
|
+
throw error;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export async function getPage(pageId, options = {}) {
|
|
561
|
+
if (!pageId) {
|
|
562
|
+
throw new ValidationError('Page ID is required');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
return await handleApiRequest('pages', 'read', { id: pageId }, options);
|
|
567
|
+
} catch (error) {
|
|
568
|
+
if (error instanceof GhostAPIError && error.ghostStatusCode === 404) {
|
|
569
|
+
throw new NotFoundError('Page', pageId);
|
|
570
|
+
}
|
|
571
|
+
throw error;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export async function getPages(options = {}) {
|
|
576
|
+
const defaultOptions = {
|
|
577
|
+
limit: 15,
|
|
578
|
+
include: 'authors',
|
|
579
|
+
...options,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
try {
|
|
583
|
+
return await handleApiRequest('pages', 'browse', {}, defaultOptions);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
console.error('Failed to get pages:', error);
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export async function searchPages(query, options = {}) {
|
|
591
|
+
// Validate query
|
|
592
|
+
if (!query || query.trim().length === 0) {
|
|
593
|
+
throw new ValidationError('Search query is required');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Sanitize query - escape special NQL characters to prevent injection
|
|
597
|
+
const sanitizedQuery = query.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
598
|
+
|
|
599
|
+
// Build filter with fuzzy title match using Ghost NQL
|
|
600
|
+
const filterParts = [`title:~'${sanitizedQuery}'`];
|
|
601
|
+
|
|
602
|
+
// Add status filter if provided and not 'all'
|
|
603
|
+
if (options.status && options.status !== 'all') {
|
|
604
|
+
filterParts.push(`status:${options.status}`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const searchOptions = {
|
|
608
|
+
limit: options.limit || 15,
|
|
609
|
+
include: 'authors',
|
|
610
|
+
filter: filterParts.join('+'),
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
return await handleApiRequest('pages', 'browse', {}, searchOptions);
|
|
615
|
+
} catch (error) {
|
|
616
|
+
console.error('Failed to search pages:', error);
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
339
621
|
export async function uploadImage(imagePath) {
|
|
340
622
|
// Validate input
|
|
341
623
|
await validators.validateImagePath(imagePath);
|
|
@@ -495,6 +777,13 @@ export default {
|
|
|
495
777
|
deletePost,
|
|
496
778
|
getPost,
|
|
497
779
|
getPosts,
|
|
780
|
+
searchPosts,
|
|
781
|
+
createPage,
|
|
782
|
+
updatePage,
|
|
783
|
+
deletePage,
|
|
784
|
+
getPage,
|
|
785
|
+
getPages,
|
|
786
|
+
searchPages,
|
|
498
787
|
uploadImage,
|
|
499
788
|
createTag,
|
|
500
789
|
getTags,
|