@jgardner04/ghost-mcp-server 1.13.5 → 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/package.json +1 -1
- package/src/__tests__/mcp_server.test.js +83 -0
- package/src/controllers/__tests__/imageController.test.js +2 -2
- package/src/controllers/imageController.js +11 -10
- package/src/mcp_server.js +261 -67
- 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__/ghostService.test.js +0 -19
- package/src/services/__tests__/imageProcessingService.test.js +148 -177
- package/src/services/__tests__/images.test.js +78 -0
- package/src/services/ghostService.js +1 -19
- package/src/services/imageProcessingService.js +100 -56
- package/src/services/images.js +34 -7
- package/src/services/pageService.js +2 -2
- package/src/utils/__tests__/imageInputResolver.test.js +134 -0
- package/src/utils/imageInputResolver.js +127 -0
|
@@ -1,204 +1,175 @@
|
|
|
1
|
-
import { describe, it, expect,
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
vi.mock('dotenv', () => mockDotenv());
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
9
8
|
vi.mock('../../utils/logger.js', () => ({
|
|
10
9
|
createContextLogger: createMockContextLogger(),
|
|
11
10
|
}));
|
|
12
11
|
|
|
13
|
-
//
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
12
|
+
// Import under test (real sharp, real filesystem)
|
|
13
|
+
const { processImage } = await import('../imageProcessingService.js');
|
|
14
|
+
|
|
15
|
+
const tmpRoot = path.join(os.tmpdir(), `ghost-mcp-img-test-${Date.now()}`);
|
|
16
|
+
const fixtures = {};
|
|
17
|
+
|
|
18
|
+
async function makeRasterFixture(format, width, height = width) {
|
|
19
|
+
const file = path.join(tmpRoot, `in-${format}-${width}.${format === 'jpeg' ? 'jpg' : format}`);
|
|
20
|
+
await sharp({
|
|
21
|
+
create: {
|
|
22
|
+
width,
|
|
23
|
+
height,
|
|
24
|
+
channels: format === 'png' ? 4 : 3,
|
|
25
|
+
background: { r: 10, g: 50, b: 150, alpha: 1 },
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
[format]()
|
|
29
|
+
.toFile(file);
|
|
30
|
+
return file;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
await fs.mkdir(tmpRoot, { recursive: true });
|
|
35
|
+
|
|
36
|
+
fixtures.pngSmall = await makeRasterFixture('png', 800);
|
|
37
|
+
fixtures.pngLarge = await makeRasterFixture('png', 2000);
|
|
38
|
+
fixtures.jpegSmall = await makeRasterFixture('jpeg', 800);
|
|
39
|
+
fixtures.jpegLarge = await makeRasterFixture('jpeg', 2000);
|
|
40
|
+
fixtures.webpSmall = await makeRasterFixture('webp', 800);
|
|
41
|
+
fixtures.webpLarge = await makeRasterFixture('webp', 2000);
|
|
42
|
+
|
|
43
|
+
// SVG: write minimal vector source
|
|
44
|
+
fixtures.svg = path.join(tmpRoot, 'in.svg');
|
|
45
|
+
await fs.writeFile(
|
|
46
|
+
fixtures.svg,
|
|
47
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#39f"/></svg>'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// GIF: sharp cannot reliably create animated GIFs across versions; use a
|
|
51
|
+
// known-valid 1x1 transparent GIF89a byte sequence for a deterministic fixture.
|
|
52
|
+
fixtures.gif = path.join(tmpRoot, 'in.gif');
|
|
53
|
+
await fs.writeFile(
|
|
54
|
+
fixtures.gif,
|
|
55
|
+
Buffer.from(
|
|
56
|
+
'47494638396101000100800000ffffff00000021f90401000001002c00000000010001000002024401003b',
|
|
57
|
+
'hex'
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// ICO: minimal 1x1 ICO header (used only for passthrough byte comparison).
|
|
62
|
+
fixtures.ico = path.join(tmpRoot, 'in.ico');
|
|
63
|
+
await fs.writeFile(
|
|
64
|
+
fixtures.ico,
|
|
65
|
+
Buffer.from(
|
|
66
|
+
'00000100010001010000010020003000000016000000280000000100000002000000010020000000000004000000000000000000000000000000000000000000000000000000000000000000000000',
|
|
67
|
+
'hex'
|
|
68
|
+
)
|
|
69
|
+
);
|
|
70
|
+
});
|
|
44
71
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const actual = await vi.importActual('path');
|
|
48
|
-
return {
|
|
49
|
-
default: actual.default,
|
|
50
|
-
...actual,
|
|
51
|
-
};
|
|
72
|
+
afterAll(async () => {
|
|
73
|
+
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
52
74
|
});
|
|
53
75
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
fs.existsSync.mockReturnValue(true);
|
|
76
|
+
async function metaOf(file) {
|
|
77
|
+
return sharp(file).metadata();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('processImage — format passthrough', () => {
|
|
81
|
+
it('PNG <= 1200: preserves format and passes through unmodified', async () => {
|
|
82
|
+
const out = await processImage(fixtures.pngSmall, tmpRoot);
|
|
83
|
+
expect(path.extname(out).toLowerCase()).toBe('.png');
|
|
84
|
+
const m = await metaOf(out);
|
|
85
|
+
expect(m.format).toBe('png');
|
|
86
|
+
expect(m.width).toBe(800);
|
|
66
87
|
});
|
|
67
88
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
expect(sharp).toHaveBeenCalledWith(inputPath);
|
|
76
|
-
expect(mockToFile).toHaveBeenCalled();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('should reject missing inputPath', async () => {
|
|
80
|
-
await expect(processImage(undefined, '/tmp/output')).rejects.toThrow(
|
|
81
|
-
'Invalid processing parameters'
|
|
82
|
-
);
|
|
83
|
-
expect(sharp).not.toHaveBeenCalled();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('should reject missing outputDir', async () => {
|
|
87
|
-
await expect(processImage('/tmp/test.jpg', undefined)).rejects.toThrow(
|
|
88
|
-
'Invalid processing parameters'
|
|
89
|
-
);
|
|
90
|
-
expect(sharp).not.toHaveBeenCalled();
|
|
91
|
-
});
|
|
89
|
+
it('PNG > 1200: resized but still PNG (no JPEG conversion)', async () => {
|
|
90
|
+
const out = await processImage(fixtures.pngLarge, tmpRoot);
|
|
91
|
+
expect(path.extname(out).toLowerCase()).toBe('.png');
|
|
92
|
+
const m = await metaOf(out);
|
|
93
|
+
expect(m.format).toBe('png');
|
|
94
|
+
expect(m.width).toBe(1200);
|
|
92
95
|
});
|
|
93
96
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
expect(fs.existsSync).toHaveBeenCalledWith(expect.stringContaining('image.jpg'));
|
|
102
|
-
expect(mockToFile).toHaveBeenCalledWith(expect.stringContaining('output'));
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('should throw error when input file does not exist', async () => {
|
|
106
|
-
fs.existsSync.mockReturnValue(false);
|
|
107
|
-
|
|
108
|
-
await expect(processImage('/tmp/nonexistent.jpg', '/tmp/output')).rejects.toThrow(
|
|
109
|
-
'Input file does not exist'
|
|
110
|
-
);
|
|
111
|
-
expect(sharp).not.toHaveBeenCalled();
|
|
112
|
-
});
|
|
97
|
+
it('JPEG <= 1200: passes through as JPEG', async () => {
|
|
98
|
+
const out = await processImage(fixtures.jpegSmall, tmpRoot);
|
|
99
|
+
expect(path.extname(out).toLowerCase()).toMatch(/\.jpe?g$/);
|
|
100
|
+
const m = await metaOf(out);
|
|
101
|
+
expect(m.format).toBe('jpeg');
|
|
102
|
+
expect(m.width).toBe(800);
|
|
113
103
|
});
|
|
114
104
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
expect(mockResize).not.toHaveBeenCalled();
|
|
122
|
-
expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
|
|
123
|
-
expect(mockToFile).toHaveBeenCalled();
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it('should resize image when width > MAX_WIDTH (1200)', async () => {
|
|
127
|
-
mockMetadata.mockResolvedValue({ width: 2000, size: 200000 });
|
|
128
|
-
|
|
129
|
-
await processImage('/tmp/large-image.jpg', '/tmp/output');
|
|
130
|
-
|
|
131
|
-
expect(mockResize).toHaveBeenCalledWith({ width: 1200 });
|
|
132
|
-
expect(mockJpeg).toHaveBeenCalledWith({ quality: 80 });
|
|
133
|
-
expect(mockToFile).toHaveBeenCalled();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it('should generate output filename with timestamp and processed prefix', async () => {
|
|
137
|
-
const inputPath = '/tmp/test-photo.jpg';
|
|
138
|
-
const outputDir = '/tmp/output';
|
|
139
|
-
|
|
140
|
-
// Mock Date.now to get predictable filename
|
|
141
|
-
const mockTimestamp = 1234567890;
|
|
142
|
-
vi.spyOn(Date, 'now').mockReturnValue(mockTimestamp);
|
|
105
|
+
it('JPEG > 1200: resized as JPEG', async () => {
|
|
106
|
+
const out = await processImage(fixtures.jpegLarge, tmpRoot);
|
|
107
|
+
const m = await metaOf(out);
|
|
108
|
+
expect(m.format).toBe('jpeg');
|
|
109
|
+
expect(m.width).toBe(1200);
|
|
110
|
+
});
|
|
143
111
|
|
|
144
|
-
|
|
112
|
+
it('WEBP <= 1200: passes through as WEBP', async () => {
|
|
113
|
+
const out = await processImage(fixtures.webpSmall, tmpRoot);
|
|
114
|
+
const m = await metaOf(out);
|
|
115
|
+
expect(m.format).toBe('webp');
|
|
116
|
+
expect(m.width).toBe(800);
|
|
117
|
+
});
|
|
145
118
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
119
|
+
it('WEBP > 1200: resized as WEBP', async () => {
|
|
120
|
+
const out = await processImage(fixtures.webpLarge, tmpRoot);
|
|
121
|
+
const m = await metaOf(out);
|
|
122
|
+
expect(m.format).toBe('webp');
|
|
123
|
+
expect(m.width).toBe(1200);
|
|
124
|
+
});
|
|
149
125
|
|
|
150
|
-
|
|
151
|
-
|
|
126
|
+
it('SVG: passthrough — output bytes identical to input (never touched by sharp)', async () => {
|
|
127
|
+
const out = await processImage(fixtures.svg, tmpRoot);
|
|
128
|
+
expect(path.extname(out).toLowerCase()).toBe('.svg');
|
|
129
|
+
const [a, b] = await Promise.all([fs.readFile(fixtures.svg), fs.readFile(out)]);
|
|
130
|
+
expect(a.equals(b)).toBe(true);
|
|
131
|
+
});
|
|
152
132
|
|
|
153
|
-
|
|
154
|
-
|
|
133
|
+
it('GIF: passthrough — output bytes identical to input (animation-safe)', async () => {
|
|
134
|
+
const out = await processImage(fixtures.gif, tmpRoot);
|
|
135
|
+
expect(path.extname(out).toLowerCase()).toBe('.gif');
|
|
136
|
+
const [a, b] = await Promise.all([fs.readFile(fixtures.gif), fs.readFile(out)]);
|
|
137
|
+
expect(a.equals(b)).toBe(true);
|
|
138
|
+
});
|
|
155
139
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
140
|
+
it('ICO: passthrough — output bytes identical to input', async () => {
|
|
141
|
+
const out = await processImage(fixtures.ico, tmpRoot, { purpose: 'icon' });
|
|
142
|
+
expect(path.extname(out).toLowerCase()).toBe('.ico');
|
|
143
|
+
const [a, b] = await Promise.all([fs.readFile(fixtures.ico), fs.readFile(out)]);
|
|
144
|
+
expect(a.equals(b)).toBe(true);
|
|
145
|
+
});
|
|
159
146
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
147
|
+
it('output path is distinct from input path', async () => {
|
|
148
|
+
const out = await processImage(fixtures.pngSmall, tmpRoot);
|
|
149
|
+
expect(out).not.toBe(fixtures.pngSmall);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
165
152
|
|
|
166
|
-
|
|
153
|
+
describe('processImage — validation & errors', () => {
|
|
154
|
+
it('rejects missing inputPath', async () => {
|
|
155
|
+
await expect(processImage(undefined, tmpRoot)).rejects.toThrow(/Invalid processing parameters/);
|
|
156
|
+
});
|
|
167
157
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
158
|
+
it('rejects missing outputDir', async () => {
|
|
159
|
+
await expect(processImage(fixtures.pngSmall, undefined)).rejects.toThrow(
|
|
160
|
+
/Invalid processing parameters/
|
|
161
|
+
);
|
|
162
|
+
});
|
|
171
163
|
|
|
172
|
-
|
|
173
|
-
|
|
164
|
+
it('throws when input file does not exist', async () => {
|
|
165
|
+
await expect(processImage('/nonexistent/xyz.png', tmpRoot)).rejects.toThrow(
|
|
166
|
+
/Input file does not exist/
|
|
167
|
+
);
|
|
174
168
|
});
|
|
175
169
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
await expect(processImage('/tmp/corrupt.jpg', '/tmp/output')).rejects.toThrow(
|
|
182
|
-
'Image processing failed: Sharp processing failed'
|
|
183
|
-
);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
it('should include original error message in re-thrown error', async () => {
|
|
187
|
-
const originalMessage = 'Input buffer contains unsupported image format';
|
|
188
|
-
mockToFile.mockRejectedValue(new Error(originalMessage));
|
|
189
|
-
|
|
190
|
-
await expect(processImage('/tmp/bad-format.dat', '/tmp/output')).rejects.toThrow(
|
|
191
|
-
`Image processing failed: ${originalMessage}`
|
|
192
|
-
);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it('should handle errors during JPEG conversion', async () => {
|
|
196
|
-
const conversionError = new Error('JPEG conversion failed');
|
|
197
|
-
mockToFile.mockRejectedValue(conversionError);
|
|
198
|
-
|
|
199
|
-
await expect(processImage('/tmp/image.jpg', '/tmp/output')).rejects.toThrow(
|
|
200
|
-
'Image processing failed: JPEG conversion failed'
|
|
201
|
-
);
|
|
202
|
-
});
|
|
170
|
+
it('wraps sharp failures with context', async () => {
|
|
171
|
+
const bogus = path.join(tmpRoot, 'bogus.png');
|
|
172
|
+
await fs.writeFile(bogus, 'not an image');
|
|
173
|
+
await expect(processImage(bogus, tmpRoot)).rejects.toThrow(/Image processing failed/);
|
|
203
174
|
});
|
|
204
175
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js';
|
|
3
|
+
import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js';
|
|
4
|
+
import { mockDotenv } from '../../__tests__/helpers/testUtils.js';
|
|
5
|
+
|
|
6
|
+
vi.mock('@tryghost/admin-api', () => mockGhostApiModule());
|
|
7
|
+
vi.mock('dotenv', () => mockDotenv());
|
|
8
|
+
vi.mock('../../utils/logger.js', () => ({
|
|
9
|
+
createContextLogger: createMockContextLogger(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// Stub path validation so these tests focus on argument shaping, not FS.
|
|
13
|
+
vi.mock('../validators.js', () => ({
|
|
14
|
+
validators: { validateImagePath: vi.fn().mockResolvedValue(undefined) },
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { uploadImage } from '../images.js';
|
|
18
|
+
import { api } from '../ghostApiClient.js';
|
|
19
|
+
import { ValidationError } from '../../errors/index.js';
|
|
20
|
+
|
|
21
|
+
describe('images.uploadImage (domain module used by MCP server)', () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
vi.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('forwards purpose and ref to the SDK', async () => {
|
|
27
|
+
const expected = { url: 'https://cdn/x.png', ref: 'original.png' };
|
|
28
|
+
api.images.upload.mockResolvedValue(expected);
|
|
29
|
+
|
|
30
|
+
const result = await uploadImage('/tmp/x.png', {
|
|
31
|
+
purpose: 'icon',
|
|
32
|
+
ref: 'original.png',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result).toEqual(expected);
|
|
36
|
+
expect(api.images.upload).toHaveBeenCalledWith({
|
|
37
|
+
file: '/tmp/x.png',
|
|
38
|
+
purpose: 'icon',
|
|
39
|
+
ref: 'original.png',
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('omits purpose/ref when not provided', async () => {
|
|
44
|
+
api.images.upload.mockResolvedValue({ url: 'https://cdn/x.png' });
|
|
45
|
+
await uploadImage('/tmp/x.png');
|
|
46
|
+
expect(api.images.upload).toHaveBeenCalledWith({ file: '/tmp/x.png' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('accepts each allowed purpose value', async () => {
|
|
50
|
+
api.images.upload.mockResolvedValue({ url: 'https://cdn/x' });
|
|
51
|
+
for (const purpose of ['image', 'profile_image', 'icon']) {
|
|
52
|
+
await uploadImage('/tmp/x.png', { purpose });
|
|
53
|
+
}
|
|
54
|
+
expect(api.images.upload).toHaveBeenCalledTimes(3);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('rejects unknown purpose', async () => {
|
|
58
|
+
await expect(uploadImage('/tmp/x.png', { purpose: 'banner' })).rejects.toBeInstanceOf(
|
|
59
|
+
ValidationError
|
|
60
|
+
);
|
|
61
|
+
expect(api.images.upload).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects non-string ref', async () => {
|
|
65
|
+
await expect(uploadImage('/tmp/x.png', { ref: 42 })).rejects.toThrow(/must be a string/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('rejects ref longer than 200 chars', async () => {
|
|
69
|
+
await expect(uploadImage('/tmp/x.png', { ref: 'x'.repeat(201) })).rejects.toThrow(
|
|
70
|
+
/cannot exceed 200/
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('accepts ref of exactly 200 chars', async () => {
|
|
75
|
+
api.images.upload.mockResolvedValue({ url: 'https://cdn/x' });
|
|
76
|
+
await expect(uploadImage('/tmp/x.png', { ref: 'x'.repeat(200) })).resolves.toBeDefined();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -148,24 +148,6 @@ const createPost = async (postData, options = { source: 'html' }) => {
|
|
|
148
148
|
return handleApiRequest('posts', 'add', dataWithDefaults, options);
|
|
149
149
|
};
|
|
150
150
|
|
|
151
|
-
/**
|
|
152
|
-
* Uploads an image to Ghost.
|
|
153
|
-
* Requires the image file path.
|
|
154
|
-
* @param {string} imagePath - The local path to the image file.
|
|
155
|
-
* @returns {Promise<object>} The result from the image upload API call, typically includes the URL of the uploaded image.
|
|
156
|
-
*/
|
|
157
|
-
const uploadImage = async (imagePath) => {
|
|
158
|
-
if (!imagePath) {
|
|
159
|
-
throw new Error('Image path is required for upload.');
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// The Ghost Admin API expects an object with a 'file' property containing the path
|
|
163
|
-
const imageData = { file: imagePath };
|
|
164
|
-
|
|
165
|
-
// Use the handleApiRequest function for consistency
|
|
166
|
-
return handleApiRequest('images', 'upload', imageData);
|
|
167
|
-
};
|
|
168
|
-
|
|
169
151
|
/**
|
|
170
152
|
* Creates a new tag in Ghost.
|
|
171
153
|
* @param {object} tagData - Data for the new tag (e.g., { name: 'New Tag', slug: 'new-tag' }).
|
|
@@ -209,4 +191,4 @@ const getTags = async (options = {}) => {
|
|
|
209
191
|
// Add other content management functions here (createTag, etc.)
|
|
210
192
|
|
|
211
193
|
// Export the API client instance and any service functions
|
|
212
|
-
export { api, getSiteInfo, handleApiRequest, createPost,
|
|
194
|
+
export { api, getSiteInfo, handleApiRequest, createPost, createTag, getTags };
|
|
@@ -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
|
+
}
|