html2pptx-local-mcp 1.1.17

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.
@@ -0,0 +1,353 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync } from 'node:fs';
4
+ import { access, readFile, realpath, stat } from 'node:fs/promises';
5
+ import { dirname, extname, join, relative, resolve, sep } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const ALLOWED_EXTENSIONS = new Set(['.html', '.htm']);
9
+ const EDITOR_SERVER_STATE_FILE = '.html2pptx/edit-slide/editor-server.json';
10
+ const LEGACY_EDITOR_SERVER_STATE_FILE = '.open-slide/editor-server.json';
11
+ const DEFAULT_LAUNCH_TIMEOUT_MS = 10000;
12
+
13
+ export function createLocalSlideEditorManager(options = {}) {
14
+ const sessions = new Map();
15
+ const rootCwd = options.cwd
16
+ ? resolve(options.cwd)
17
+ : resolve(process.env.CLAUDE_PROJECT_DIR || process.cwd());
18
+ const launchTimeoutMs = Number.isFinite(options.launchTimeoutMs)
19
+ ? Math.max(1000, Math.floor(options.launchTimeoutMs))
20
+ : DEFAULT_LAUNCH_TIMEOUT_MS;
21
+
22
+ async function open(input = {}) {
23
+ const file = await resolveEditableFile(input.filePath, rootCwd);
24
+ const baseUrl = normalizeEditorBaseUrl(
25
+ input.baseUrl || process.env.HTML2PPTX_EDITOR_BASE_URL || await readRegisteredEditorBaseUrl(file.root),
26
+ );
27
+ const port = normalizePort(input.port, 0);
28
+ const openBrowser = input.openBrowser !== false;
29
+ const invocation = resolveCliInvocation(options);
30
+ const childArgs = [
31
+ ...invocation.baseArgs,
32
+ 'edit',
33
+ file.relativePath,
34
+ '--json',
35
+ '--base-url',
36
+ baseUrl,
37
+ '--port',
38
+ String(port),
39
+ ];
40
+ if (!openBrowser) childArgs.push('--no-open');
41
+
42
+ const child = spawn(invocation.command, childArgs, {
43
+ cwd: file.root,
44
+ env: {
45
+ ...process.env,
46
+ NO_COLOR: '1',
47
+ },
48
+ stdio: ['ignore', 'pipe', 'pipe'],
49
+ });
50
+
51
+ const session = {
52
+ id: randomUUID(),
53
+ child,
54
+ command: invocation.command,
55
+ args: childArgs,
56
+ filePath: file.absolutePath,
57
+ relativePath: file.relativePath,
58
+ root: file.root,
59
+ startedAt: new Date().toISOString(),
60
+ stdout: '',
61
+ stderr: '',
62
+ details: null,
63
+ };
64
+ sessions.set(session.id, session);
65
+
66
+ child.stdout.setEncoding('utf8');
67
+ child.stderr.setEncoding('utf8');
68
+
69
+ child.stdout.on('data', (chunk) => {
70
+ session.stdout += chunk;
71
+ });
72
+ child.stderr.on('data', (chunk) => {
73
+ session.stderr += chunk;
74
+ });
75
+ child.once('exit', () => {
76
+ sessions.delete(session.id);
77
+ });
78
+
79
+ const details = await waitForBridgeDetails(session, launchTimeoutMs);
80
+ session.details = details;
81
+ return redactSessionForResponse(session);
82
+ }
83
+
84
+ async function stop(sessionId) {
85
+ if (typeof sessionId !== 'string' || sessionId.trim() === '') {
86
+ throw new Error('sessionId must be a non-empty string.');
87
+ }
88
+ const session = sessions.get(sessionId);
89
+ if (!session) {
90
+ throw new Error(`Local slide editor session not found: ${sessionId}`);
91
+ }
92
+ session.child.kill('SIGTERM');
93
+ sessions.delete(sessionId);
94
+ return {
95
+ sessionId,
96
+ stopped: true,
97
+ file: session.relativePath,
98
+ };
99
+ }
100
+
101
+ function list() {
102
+ return Array.from(sessions.values(), redactSessionForResponse);
103
+ }
104
+
105
+ function stopAll() {
106
+ for (const session of sessions.values()) {
107
+ session.child.kill('SIGTERM');
108
+ }
109
+ sessions.clear();
110
+ }
111
+
112
+ return {
113
+ open,
114
+ stop,
115
+ list,
116
+ stopAll,
117
+ };
118
+ }
119
+
120
+ export const localSlideEditorManager = createLocalSlideEditorManager();
121
+
122
+ export async function resolveEditableFile(filePath, cwd = process.cwd()) {
123
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
124
+ throw new Error('filePath must be a non-empty .html or .htm path.');
125
+ }
126
+
127
+ const root = await resolveRealpath(cwd);
128
+ const absolutePath = resolve(root, filePath);
129
+ if (absolutePath !== root && !absolutePath.startsWith(root + sep)) {
130
+ throw new Error('filePath must be inside the MCP server working directory.');
131
+ }
132
+
133
+ const extension = extname(absolutePath).toLowerCase();
134
+ if (!ALLOWED_EXTENSIONS.has(extension)) {
135
+ throw new Error('Only .html and .htm files can be opened in the local slide editor.');
136
+ }
137
+
138
+ const info = await stat(absolutePath).catch(() => null);
139
+ if (!info || !info.isFile()) {
140
+ throw new Error(`HTML file not found: ${filePath}`);
141
+ }
142
+
143
+ const realFile = await resolveRealpath(absolutePath);
144
+ if (realFile !== root && !realFile.startsWith(root + sep)) {
145
+ throw new Error('filePath must not escape the working directory through a symlink.');
146
+ }
147
+
148
+ return {
149
+ root,
150
+ absolutePath: realFile,
151
+ relativePath: toPosixPath(relative(root, realFile)),
152
+ };
153
+ }
154
+
155
+ export function resolveCliInvocation(options = {}) {
156
+ if (options.command) {
157
+ return {
158
+ command: options.command,
159
+ baseArgs: Array.isArray(options.baseArgs) ? options.baseArgs : [],
160
+ };
161
+ }
162
+
163
+ const envCommand = process.env.HTML2PPTX_CLI_BIN;
164
+ if (envCommand) {
165
+ return {
166
+ command: envCommand,
167
+ baseArgs: [],
168
+ };
169
+ }
170
+
171
+ const bundledCli = fileURLToPath(new URL('../cli/dist/index.js', import.meta.url));
172
+ if (existsSync(bundledCli)) {
173
+ return {
174
+ command: process.execPath,
175
+ baseArgs: [bundledCli],
176
+ };
177
+ }
178
+
179
+ return {
180
+ command: 'html2pptx',
181
+ baseArgs: [],
182
+ };
183
+ }
184
+
185
+ export function parseBridgeDetails(stdout) {
186
+ const lines = String(stdout || '').split(/\r?\n/);
187
+ for (const line of lines) {
188
+ const trimmed = line.trim();
189
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue;
190
+ try {
191
+ const parsed = JSON.parse(trimmed);
192
+ if (parsed?.success && parsed?.editorUrl && parsed?.bridgeUrl) {
193
+ return parsed;
194
+ }
195
+ } catch {
196
+ // Ignore non-JSON log lines.
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function waitForBridgeDetails(session, timeoutMs) {
203
+ return new Promise((resolvePromise, rejectPromise) => {
204
+ let done = false;
205
+
206
+ const cleanup = () => {
207
+ session.child.stdout.off('data', onStdout);
208
+ session.child.off('exit', onExit);
209
+ clearTimeout(timer);
210
+ };
211
+
212
+ const finish = (fn, value) => {
213
+ if (done) return;
214
+ done = true;
215
+ cleanup();
216
+ fn(value);
217
+ };
218
+
219
+ const onStdout = () => {
220
+ const details = parseBridgeDetails(session.stdout);
221
+ if (details) finish(resolvePromise, details);
222
+ };
223
+
224
+ const onExit = (code, signal) => {
225
+ finish(
226
+ rejectPromise,
227
+ new Error(
228
+ [
229
+ `html2pptx edit exited before the local bridge was ready (code ${code ?? 'null'}, signal ${signal ?? 'none'}).`,
230
+ session.stderr.trim() || session.stdout.trim(),
231
+ ].filter(Boolean).join(' '),
232
+ ),
233
+ );
234
+ };
235
+
236
+ const timer = setTimeout(() => {
237
+ session.child.kill('SIGTERM');
238
+ finish(
239
+ rejectPromise,
240
+ new Error(`Timed out after ${timeoutMs}ms while starting the local slide editor bridge.`),
241
+ );
242
+ }, timeoutMs);
243
+
244
+ session.child.stdout.on('data', onStdout);
245
+ session.child.once('exit', onExit);
246
+ onStdout();
247
+ });
248
+ }
249
+
250
+ function redactSessionForResponse(session) {
251
+ const details = session.details || {};
252
+ return {
253
+ sessionId: session.id,
254
+ pid: session.child.pid,
255
+ editorUrl: details.editorUrl,
256
+ bridgeUrl: details.bridgeUrl,
257
+ sessionTokenRequired: details.sessionTokenRequired !== false,
258
+ file: details.file || session.relativePath,
259
+ root: details.root || session.root,
260
+ startedAt: session.startedAt,
261
+ };
262
+ }
263
+
264
+ async function resolveRealpath(path) {
265
+ try {
266
+ await access(path);
267
+ return await realpath(path);
268
+ } catch (error) {
269
+ if (path === dirname(path)) throw error;
270
+ const parent = await resolveRealpath(dirname(path));
271
+ return resolve(parent, relative(dirname(path), path));
272
+ }
273
+ }
274
+
275
+ export function normalizeEditorBaseUrl(raw) {
276
+ try {
277
+ if (!raw) {
278
+ throw new Error(
279
+ 'Local editor UI is not registered. Start it with `node scripts/dev-studio.mjs`, then retry, or pass baseUrl as a loopback editor URL.',
280
+ );
281
+ }
282
+ const url = new URL(raw);
283
+ url.search = '';
284
+ url.hash = '';
285
+ if (!isAllowedEditorBaseUrl(url)) {
286
+ throw new Error(
287
+ 'baseUrl for local file editing must be a loopback http(s) origin such as http://localhost:<port>. Hosted editor URLs are not allowed.',
288
+ );
289
+ }
290
+ return url.toString().replace(/\/$/, '');
291
+ } catch (error) {
292
+ if (error?.message?.startsWith('baseUrl must be ')) throw error;
293
+ if (error?.message?.startsWith('baseUrl for local file editing')) throw error;
294
+ if (error?.message?.startsWith('Local editor UI is not registered')) throw error;
295
+ throw new Error('baseUrl must be a valid http(s) URL.');
296
+ }
297
+ }
298
+
299
+ function isAllowedEditorBaseUrl(url) {
300
+ if (!['http:', 'https:'].includes(url.protocol)) return false;
301
+ return isLoopbackHostname(url.hostname);
302
+ }
303
+
304
+ export async function readRegisteredEditorBaseUrl(root = process.cwd()) {
305
+ for (const stateFile of [EDITOR_SERVER_STATE_FILE, LEGACY_EDITOR_SERVER_STATE_FILE]) {
306
+ try {
307
+ const raw = await readFile(join(root, stateFile), 'utf8');
308
+ const state = JSON.parse(raw);
309
+ if (typeof state?.baseUrl === 'string') return state.baseUrl;
310
+ } catch {
311
+ // Try the next known state location.
312
+ }
313
+ }
314
+ return null;
315
+ }
316
+
317
+ function isLoopbackHostname(hostname) {
318
+ const host = String(hostname || '').replace(/^\[|\]$/g, '').toLowerCase();
319
+ return (
320
+ host === 'localhost' ||
321
+ host === '::1' ||
322
+ host === '0:0:0:0:0:0:0:1' ||
323
+ /^127(?:\.\d{1,3}){3}$/.test(host)
324
+ );
325
+ }
326
+
327
+ function normalizePort(value, fallback) {
328
+ if (value == null || value === '') return fallback;
329
+ const port = Number.parseInt(String(value), 10);
330
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
331
+ throw new Error('port must be an integer from 0 to 65535.');
332
+ }
333
+ return port;
334
+ }
335
+
336
+ function toPosixPath(filePath) {
337
+ return filePath.split(sep).join('/');
338
+ }
339
+
340
+ function installExitHandlers() {
341
+ const stop = () => localSlideEditorManager.stopAll();
342
+ process.once('exit', stop);
343
+ process.once('SIGINT', () => {
344
+ stop();
345
+ process.exit(130);
346
+ });
347
+ process.once('SIGTERM', () => {
348
+ stop();
349
+ process.exit(143);
350
+ });
351
+ }
352
+
353
+ installExitHandlers();