html2pptx-local-mcp 1.1.19 → 1.1.20
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/app/docs/content.js +10 -10
- package/cli/dist/commands/edit.js +214 -3
- package/package.json +4 -2
- package/scripts/extract-html2pptx-comments.mjs +172 -0
- package/scripts/install-mcp.mjs +30 -11
package/app/docs/content.js
CHANGED
|
@@ -186,7 +186,7 @@ npx skills add https://html2pptx.app --list
|
|
|
186
186
|
npx skills add https://html2pptx.app
|
|
187
187
|
|
|
188
188
|
# Claude Code users: run this one line
|
|
189
|
-
npx
|
|
189
|
+
npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
|
|
190
190
|
|
|
191
191
|
const SKILL_INSTALL_COMMAND_JA = `# 使うエージェントのコマンドを選択
|
|
192
192
|
# Claude Code
|
|
@@ -208,7 +208,7 @@ npx skills add https://html2pptx.app --list
|
|
|
208
208
|
npx skills add https://html2pptx.app
|
|
209
209
|
|
|
210
210
|
# Claude Codeユーザーはこの1行でOK
|
|
211
|
-
npx
|
|
211
|
+
npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
|
|
212
212
|
|
|
213
213
|
const SKILL_INVOCATION_EXAMPLE = `# 例: Claude Code でスライド作成 → PPTX出力
|
|
214
214
|
|
|
@@ -244,7 +244,7 @@ const SKILL_AVAILABLE_LIST = [
|
|
|
244
244
|
];
|
|
245
245
|
|
|
246
246
|
const MCP_INSTALL_EXAMPLE = `# Claude Codeユーザーはこの1行でOK
|
|
247
|
-
npx
|
|
247
|
+
npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude`;
|
|
248
248
|
|
|
249
249
|
const MCP_CONFIG_EXAMPLE = `// 方法2: 設定ファイルに手動追加
|
|
250
250
|
|
|
@@ -256,7 +256,7 @@ const MCP_CONFIG_EXAMPLE = `// 方法2: 設定ファイルに手動追加
|
|
|
256
256
|
"mcpServers": {
|
|
257
257
|
"html2pptx": {
|
|
258
258
|
"command": "npx",
|
|
259
|
-
"args": ["
|
|
259
|
+
"args": ["--yes", "--package", "html2pptx-local-mcp@latest", "html2pptx-mcp"],
|
|
260
260
|
"env": {
|
|
261
261
|
"PPTX_STUDIO_API_KEY": "sk_live_xxxx",
|
|
262
262
|
"PPTX_STUDIO_BASE_URL": "http://127.0.0.1:<app-port>"
|
|
@@ -858,9 +858,9 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
|
|
|
858
858
|
icon: 'https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/claude/default.svg',
|
|
859
859
|
recommended: true,
|
|
860
860
|
steps: [
|
|
861
|
-
{ title: 'Claude Code users: run this one line', body: 'This registers remote export as `html2pptx`, then writes local edit-slide as `html2pptx-local` directly into Claude Code user config.', code: `npx
|
|
861
|
+
{ title: 'Claude Code users: run this one line', body: 'This registers remote export as `html2pptx`, then writes local edit-slide as `html2pptx-local` directly into Claude Code user config.', code: `npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
|
|
862
862
|
{ title: 'Manual remote setup', body: 'If you only need hosted export tools, add the remote MCP server directly.', code: `claude mcp add --scope user --transport http html2pptx https://html2pptx.app/mcp` },
|
|
863
|
-
{ title: 'Compatible local edit-slide setup', body: 'If Claude Code stdio registration is unreliable, use the installer because it writes the local server entry directly.', code: `npx
|
|
863
|
+
{ title: 'Compatible local edit-slide setup', body: 'If Claude Code stdio registration is unreliable, use the installer because it writes the local server entry directly.', code: `npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
|
|
864
864
|
],
|
|
865
865
|
tip: 'The installer registers the remote MCP with user scope and writes the local stdio MCP directly to Claude Code user config for better compatibility.',
|
|
866
866
|
},
|
|
@@ -871,7 +871,7 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
|
|
|
871
871
|
steps: [
|
|
872
872
|
{ title: 'Run the CLI command (recommended)', body: 'Add the hosted remote MCP server. OAuth authentication will start automatically.', code: `codex mcp add html2pptx --url https://html2pptx.app/mcp` },
|
|
873
873
|
{ title: 'Manual remote setup', body: 'If you only need hosted export tools, add the remote MCP server directly.', code: `codex mcp add html2pptx --url https://html2pptx.app/mcp` },
|
|
874
|
-
{ title: 'Optional local stdio MCP for edit-slide', body: 'Use this only when Codex needs to launch local edit-slide for a `.html` file. It runs from the published package, so no repository checkout is required.', code: `codex mcp add html2pptx-local -- npx
|
|
874
|
+
{ title: 'Optional local stdio MCP for edit-slide', body: 'Use this only when Codex needs to launch local edit-slide for a `.html` file. It runs from the published package, so no repository checkout is required.', code: `codex mcp add html2pptx-local -- npx --yes --package html2pptx-local-mcp@latest html2pptx-mcp` },
|
|
875
875
|
{ title: 'Manual setup via codex.json', body: 'Alternatively, create or edit codex.json in your project root.', code: `{\n "mcpServers": {\n "html2pptx": {\n "type": "url",\n "url": "https://html2pptx.app/mcp"\n }\n }\n}` },
|
|
876
876
|
],
|
|
877
877
|
},
|
|
@@ -1702,9 +1702,9 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
|
|
|
1702
1702
|
icon: 'https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/claude/default.svg',
|
|
1703
1703
|
recommended: true,
|
|
1704
1704
|
steps: [
|
|
1705
|
-
{ title: 'Claude Codeユーザーはこの1行でOK', body: '`html2pptx` として remote export を登録し、`html2pptx-local` として local edit-slide を Claude Code の user config に直接書き込みます。', code: `npx
|
|
1705
|
+
{ title: 'Claude Codeユーザーはこの1行でOK', body: '`html2pptx` として remote export を登録し、`html2pptx-local` として local edit-slide を Claude Code の user config に直接書き込みます。', code: `npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
|
|
1706
1706
|
{ title: 'remote だけ手動セットアップ', body: 'PPTX出力などのホスト側ツールだけ必要な場合はこちらを使います。', code: `claude mcp add --scope user --transport http html2pptx https://html2pptx.app/mcp` },
|
|
1707
|
-
{ title: 'local edit-slide 互換セットアップ', body: 'Claude Code の stdio 登録が不安定な場合も、このインストーラーが local server 設定を直接書き込みます。', code: `npx
|
|
1707
|
+
{ title: 'local edit-slide 互換セットアップ', body: 'Claude Code の stdio 登録が不安定な場合も、このインストーラーが local server 設定を直接書き込みます。', code: `npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude` },
|
|
1708
1708
|
],
|
|
1709
1709
|
tip: 'このインストーラーは remote MCP を user scope で登録し、互換性のため local stdio MCP は Claude Code の user config に直接書き込みます。',
|
|
1710
1710
|
},
|
|
@@ -1715,7 +1715,7 @@ echo 'HTML2PPTX_API_KEY=sk_live_xxxx' >> .env`,
|
|
|
1715
1715
|
steps: [
|
|
1716
1716
|
{ title: 'CLIコマンドで追加(推奨)', body: 'hosted remote MCP server を追加します。OAuth認証が自動的に開始されます。', code: `codex mcp add html2pptx --url https://html2pptx.app/mcp` },
|
|
1717
1717
|
{ title: 'remote だけ手動セットアップ', body: 'PPTX出力などのホスト側ツールだけ必要な場合はこちらを使います。', code: `codex mcp add html2pptx --url https://html2pptx.app/mcp` },
|
|
1718
|
-
{ title: 'edit-slide 用 local stdio MCP(任意)', body: 'Codex がローカル `.html` を edit-slide で開く必要がある場合だけ追加します。公開パッケージから起動するため、リポジトリの checkout は不要です。', code: `codex mcp add html2pptx-local -- npx
|
|
1718
|
+
{ title: 'edit-slide 用 local stdio MCP(任意)', body: 'Codex がローカル `.html` を edit-slide で開く必要がある場合だけ追加します。公開パッケージから起動するため、リポジトリの checkout は不要です。', code: `codex mcp add html2pptx-local -- npx --yes --package html2pptx-local-mcp@latest html2pptx-mcp` },
|
|
1719
1719
|
{ title: 'codex.json で手動セットアップ', body: 'コマンドが使えない場合は、プロジェクトルートの codex.json に以下を追加してください。', code: `{\n "mcpServers": {\n "html2pptx": {\n "type": "url",\n "url": "https://html2pptx.app/mcp"\n }\n }\n}` },
|
|
1720
1720
|
],
|
|
1721
1721
|
},
|
|
@@ -2,14 +2,34 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { platform } from "node:os";
|
|
5
|
-
import { mkdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { mkdir, readdir, readFile, realpath, stat, writeFile, } from "node:fs/promises";
|
|
6
6
|
import { dirname, extname, join, relative, resolve, sep, } from "node:path";
|
|
7
7
|
import * as p from "@clack/prompts";
|
|
8
8
|
import pc from "picocolors";
|
|
9
9
|
const AUTO_PORT = 0;
|
|
10
10
|
const MAX_WRITE_BYTES = 5 * 1024 * 1024;
|
|
11
|
+
const MAX_ASSET_BYTES = 8 * 1024 * 1024;
|
|
11
12
|
const ALLOWED_EXTENSIONS = [".html", ".htm"];
|
|
12
13
|
const ALLOWED_EXT = new Set(ALLOWED_EXTENSIONS);
|
|
14
|
+
const ASSET_IMAGE_EXTENSIONS = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"];
|
|
15
|
+
const ASSET_IMAGE_EXT = new Set(ASSET_IMAGE_EXTENSIONS);
|
|
16
|
+
const ASSET_CONTENT_TYPES = {
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".jpg": "image/jpeg",
|
|
19
|
+
".jpeg": "image/jpeg",
|
|
20
|
+
".gif": "image/gif",
|
|
21
|
+
".webp": "image/webp",
|
|
22
|
+
".svg": "image/svg+xml",
|
|
23
|
+
".avif": "image/avif",
|
|
24
|
+
};
|
|
25
|
+
const ASSET_CONTENT_TYPE_EXT = {
|
|
26
|
+
"image/png": ".png",
|
|
27
|
+
"image/jpeg": ".jpg",
|
|
28
|
+
"image/gif": ".gif",
|
|
29
|
+
"image/webp": ".webp",
|
|
30
|
+
"image/svg+xml": ".svg",
|
|
31
|
+
"image/avif": ".avif",
|
|
32
|
+
};
|
|
13
33
|
const DISALLOWED_TOP_DIRECTORIES = [
|
|
14
34
|
"public",
|
|
15
35
|
".next",
|
|
@@ -32,6 +52,9 @@ const EDITOR_BASE_URL_EXAMPLE = "http://localhost:<port>";
|
|
|
32
52
|
function sha256(content) {
|
|
33
53
|
return createHash("sha256").update(content).digest("hex");
|
|
34
54
|
}
|
|
55
|
+
function sha256Buffer(buf) {
|
|
56
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
57
|
+
}
|
|
35
58
|
function generateSessionToken() {
|
|
36
59
|
return randomBytes(32).toString("base64url");
|
|
37
60
|
}
|
|
@@ -278,13 +301,13 @@ function validateWriteRequest(req, ctx, reqUrl) {
|
|
|
278
301
|
return (req.headers["x-edit-slide-local"] === "1" ||
|
|
279
302
|
req.headers["x-open-slide-local"] === "1");
|
|
280
303
|
}
|
|
281
|
-
async function readBody(req) {
|
|
304
|
+
async function readBody(req, maxBytes = MAX_WRITE_BYTES) {
|
|
282
305
|
const chunks = [];
|
|
283
306
|
let total = 0;
|
|
284
307
|
for await (const chunk of req) {
|
|
285
308
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
286
309
|
total += buf.length;
|
|
287
|
-
if (total >
|
|
310
|
+
if (total > maxBytes + 1024 * 1024) {
|
|
288
311
|
throw new Error("request body too large");
|
|
289
312
|
}
|
|
290
313
|
chunks.push(buf);
|
|
@@ -427,6 +450,182 @@ async function handlePost(ctx, req, res) {
|
|
|
427
450
|
sendJson(res, 400, { error: error?.message || "write failed" });
|
|
428
451
|
}
|
|
429
452
|
}
|
|
453
|
+
async function safeAssetPath(ctx, rel) {
|
|
454
|
+
if (typeof rel !== "string" || !rel) {
|
|
455
|
+
throw new Error("missing path");
|
|
456
|
+
}
|
|
457
|
+
const normalized = rel.replace(/^\/+/, "");
|
|
458
|
+
const abs = resolve(ctx.root, normalized);
|
|
459
|
+
if (abs !== ctx.root && !abs.startsWith(ctx.root + sep)) {
|
|
460
|
+
throw new Error("path escape");
|
|
461
|
+
}
|
|
462
|
+
const ext = extname(abs).toLowerCase();
|
|
463
|
+
if (!ASSET_IMAGE_EXT.has(ext)) {
|
|
464
|
+
throw new Error("only image files are allowed");
|
|
465
|
+
}
|
|
466
|
+
const real = await resolveReal(abs);
|
|
467
|
+
if (real !== ctx.root && !real.startsWith(ctx.root + sep)) {
|
|
468
|
+
throw new Error("path escape via symlink");
|
|
469
|
+
}
|
|
470
|
+
for (const candidate of [relative(ctx.root, abs), relative(ctx.root, real)]) {
|
|
471
|
+
const first = candidate.split(sep)[0];
|
|
472
|
+
if (DISALLOWED_TOP_DIRS.has(first)) {
|
|
473
|
+
throw new Error(`assets under ${first}/ are not allowed`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return real;
|
|
477
|
+
}
|
|
478
|
+
function assetExt(name, contentType) {
|
|
479
|
+
const fromName = extname(String(name || "")).toLowerCase();
|
|
480
|
+
if (ASSET_IMAGE_EXT.has(fromName))
|
|
481
|
+
return fromName;
|
|
482
|
+
return ASSET_CONTENT_TYPE_EXT[String(contentType || "").toLowerCase()] || "";
|
|
483
|
+
}
|
|
484
|
+
function assetSlug(name) {
|
|
485
|
+
const base = String(name || "image").replace(/\.[^.]+$/, "");
|
|
486
|
+
const slug = base
|
|
487
|
+
.normalize("NFKD")
|
|
488
|
+
.replace(/[^\w.-]+/g, "-")
|
|
489
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
490
|
+
.toLowerCase();
|
|
491
|
+
return slug || "image";
|
|
492
|
+
}
|
|
493
|
+
function assetDirRel(scope, htmlDirRel) {
|
|
494
|
+
if (scope === "global")
|
|
495
|
+
return "assets";
|
|
496
|
+
return htmlDirRel ? join(htmlDirRel, "assets") : "assets";
|
|
497
|
+
}
|
|
498
|
+
async function readBytesOrNull(abs) {
|
|
499
|
+
try {
|
|
500
|
+
return await readFile(abs);
|
|
501
|
+
}
|
|
502
|
+
catch (error) {
|
|
503
|
+
if (error?.code === "ENOENT")
|
|
504
|
+
return null;
|
|
505
|
+
throw error;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
function sendBinary(res, status, contentType, buf) {
|
|
509
|
+
res.statusCode = status;
|
|
510
|
+
res.setHeader("content-type", contentType);
|
|
511
|
+
res.setHeader("cache-control", "no-store");
|
|
512
|
+
res.setHeader("x-content-type-options", "nosniff");
|
|
513
|
+
res.setHeader("content-security-policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'");
|
|
514
|
+
res.end(buf);
|
|
515
|
+
}
|
|
516
|
+
async function handleAssetGet(ctx, req, reqUrl, res) {
|
|
517
|
+
if (!validateBridgeRequest(req, ctx, reqUrl)) {
|
|
518
|
+
sendJson(res, 403, { error: "forbidden" });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const file = reqUrl.searchParams.get("file") || "";
|
|
522
|
+
const htmlDirRel = file ? dirname(file) : "";
|
|
523
|
+
if (reqUrl.searchParams.get("list") === "1") {
|
|
524
|
+
const scope = reqUrl.searchParams.get("scope") === "global" ? "global" : "project";
|
|
525
|
+
const dirRel = assetDirRel(scope, htmlDirRel);
|
|
526
|
+
const absDir = resolve(ctx.root, dirRel);
|
|
527
|
+
const htmlDirAbs = resolve(ctx.root, htmlDirRel || ".");
|
|
528
|
+
let assets = [];
|
|
529
|
+
try {
|
|
530
|
+
if (absDir === ctx.root || absDir.startsWith(ctx.root + sep)) {
|
|
531
|
+
const names = await readdir(absDir);
|
|
532
|
+
assets = names
|
|
533
|
+
.filter((name) => ASSET_IMAGE_EXT.has(extname(name).toLowerCase()))
|
|
534
|
+
.map((name) => ({
|
|
535
|
+
name,
|
|
536
|
+
src: toPosixPath(relative(htmlDirAbs, join(absDir, name))),
|
|
537
|
+
}));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
assets = [];
|
|
542
|
+
}
|
|
543
|
+
sendJson(res, 200, { assets, policy: buildPolicy(ctx) });
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const src = reqUrl.searchParams.get("src") || "";
|
|
547
|
+
if (!src) {
|
|
548
|
+
sendJson(res, 400, { error: "missing src" });
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
const abs = await safeAssetPath(ctx, join(htmlDirRel, src));
|
|
553
|
+
const buf = await readFile(abs);
|
|
554
|
+
const ext = extname(abs).toLowerCase();
|
|
555
|
+
sendBinary(res, 200, ASSET_CONTENT_TYPES[ext] || "application/octet-stream", buf);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
const message = error?.message || "read failed";
|
|
559
|
+
const status = message.includes("ENOENT") ? 404 : 400;
|
|
560
|
+
sendJson(res, status, { error: message });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function handleAssetPost(ctx, req, res) {
|
|
564
|
+
const reqUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
565
|
+
if (!validateWriteRequest(req, ctx, reqUrl)) {
|
|
566
|
+
sendJson(res, 403, { error: "forbidden" });
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
let body;
|
|
570
|
+
try {
|
|
571
|
+
body = JSON.parse(await readBody(req, MAX_ASSET_BYTES * 2));
|
|
572
|
+
}
|
|
573
|
+
catch (error) {
|
|
574
|
+
sendJson(res, 400, { error: error?.message || "invalid json" });
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const { file, scope: rawScope, name, contentType, dataBase64 } = body || {};
|
|
578
|
+
if (typeof dataBase64 !== "string" || !dataBase64) {
|
|
579
|
+
sendJson(res, 400, { error: "missing image data" });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const buf = Buffer.from(dataBase64, "base64");
|
|
583
|
+
if (!buf.length) {
|
|
584
|
+
sendJson(res, 400, { error: "empty image data" });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (buf.length > MAX_ASSET_BYTES) {
|
|
588
|
+
sendJson(res, 413, { error: "image too large (>8MB)" });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
const ext = assetExt(name, contentType);
|
|
592
|
+
if (!ASSET_IMAGE_EXT.has(ext)) {
|
|
593
|
+
sendJson(res, 400, { error: "unsupported image type" });
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const scope = rawScope === "global" ? "global" : "project";
|
|
597
|
+
const htmlDirRel = typeof file === "string" && file ? dirname(file) : "";
|
|
598
|
+
const htmlDirAbs = resolve(ctx.root, htmlDirRel || ".");
|
|
599
|
+
const dirRel = assetDirRel(scope, htmlDirRel);
|
|
600
|
+
const slug = assetSlug(name);
|
|
601
|
+
try {
|
|
602
|
+
let fileName = `${slug}${ext}`;
|
|
603
|
+
let absTarget = await safeAssetPath(ctx, join(dirRel, fileName));
|
|
604
|
+
const existing = await readBytesOrNull(absTarget);
|
|
605
|
+
if (existing && !existing.equals(buf)) {
|
|
606
|
+
fileName = `${slug}-${sha256Buffer(buf).slice(0, 8)}${ext}`;
|
|
607
|
+
absTarget = await safeAssetPath(ctx, join(dirRel, fileName));
|
|
608
|
+
}
|
|
609
|
+
const alreadyIdentical = Boolean(existing && existing.equals(buf));
|
|
610
|
+
if (!alreadyIdentical) {
|
|
611
|
+
await mkdir(dirname(absTarget), { recursive: true });
|
|
612
|
+
await writeFile(absTarget, buf);
|
|
613
|
+
}
|
|
614
|
+
sendJson(res, 200, {
|
|
615
|
+
ok: true,
|
|
616
|
+
scope,
|
|
617
|
+
name: fileName,
|
|
618
|
+
path: toPosixPath(relative(ctx.root, absTarget)),
|
|
619
|
+
src: toPosixPath(relative(htmlDirAbs, absTarget)),
|
|
620
|
+
bytes: buf.length,
|
|
621
|
+
reused: alreadyIdentical,
|
|
622
|
+
policy: buildPolicy(ctx),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
sendJson(res, 400, { error: error?.message || "asset write failed" });
|
|
627
|
+
}
|
|
628
|
+
}
|
|
430
629
|
function createBridgeServer(ctx) {
|
|
431
630
|
return createServer(async (req, res) => {
|
|
432
631
|
if (!applyCors(req, res, ctx.editorOrigin))
|
|
@@ -445,6 +644,18 @@ function createBridgeServer(ctx) {
|
|
|
445
644
|
sendJson(res, 200, { ok: true, service: "html2pptx edit bridge", root: ctx.root });
|
|
446
645
|
return;
|
|
447
646
|
}
|
|
647
|
+
if (reqUrl.pathname === "/api/edit-slide/asset") {
|
|
648
|
+
if (req.method === "GET") {
|
|
649
|
+
await handleAssetGet(ctx, req, reqUrl, res);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (req.method === "POST") {
|
|
653
|
+
await handleAssetPost(ctx, req, res);
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
448
659
|
if (reqUrl.pathname !== "/api/edit-slide/file") {
|
|
449
660
|
sendJson(res, 404, { error: "not found" });
|
|
450
661
|
return;
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "html2pptx-local-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.20",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Local stdio MCP server for opening html2pptx slide HTML in the local edit-slide editor.",
|
|
6
6
|
"bin": {
|
|
7
7
|
"html2pptx-mcp": "./mcp/pptx-studio-mcp-server.mjs",
|
|
8
|
-
"html2pptx-install-mcp": "./scripts/install-mcp.mjs"
|
|
8
|
+
"html2pptx-install-mcp": "./scripts/install-mcp.mjs",
|
|
9
|
+
"html2pptx-comments": "./scripts/extract-html2pptx-comments.mjs"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"app/docs/content.js",
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
"lib/server/template-html-policy.mjs",
|
|
17
18
|
"mcp/pptx-studio-mcp-server.mjs",
|
|
18
19
|
"scripts/install-mcp.mjs",
|
|
20
|
+
"scripts/extract-html2pptx-comments.mjs",
|
|
19
21
|
"src/animation-injector.js",
|
|
20
22
|
"src/animation-renderers.js"
|
|
21
23
|
],
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { extname, resolve } from 'node:path';
|
|
5
|
+
import { JSDOM } from 'jsdom';
|
|
6
|
+
|
|
7
|
+
const ALLOWED_EXTENSIONS = new Set(['.html', '.htm']);
|
|
8
|
+
|
|
9
|
+
function usage() {
|
|
10
|
+
return [
|
|
11
|
+
'Usage: html2pptx-comments <file.html> [--json|--format markdown|--format json]',
|
|
12
|
+
'',
|
|
13
|
+
'Extracts edit-slide element comments saved as data-html2pptx-comment attributes.',
|
|
14
|
+
].join('\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseArgs(argv) {
|
|
18
|
+
const args = argv.slice(2);
|
|
19
|
+
let file = '';
|
|
20
|
+
let format = 'markdown';
|
|
21
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
22
|
+
const arg = args[i];
|
|
23
|
+
if (arg === '--help' || arg === '-h') {
|
|
24
|
+
return { help: true, file, format };
|
|
25
|
+
}
|
|
26
|
+
if (arg === '--json') {
|
|
27
|
+
format = 'json';
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === '--format') {
|
|
31
|
+
const value = args[i + 1];
|
|
32
|
+
if (!['json', 'markdown'].includes(value)) {
|
|
33
|
+
throw new Error('--format must be "json" or "markdown".');
|
|
34
|
+
}
|
|
35
|
+
format = value;
|
|
36
|
+
i += 1;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (arg.startsWith('-')) {
|
|
40
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
41
|
+
}
|
|
42
|
+
if (file) {
|
|
43
|
+
throw new Error(`Unexpected extra file argument: ${arg}`);
|
|
44
|
+
}
|
|
45
|
+
file = arg;
|
|
46
|
+
}
|
|
47
|
+
return { help: false, file, format };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function cssEscape(value) {
|
|
51
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function shortText(value, max = 80) {
|
|
55
|
+
const text = String(value || '').trim().replace(/\s+/g, ' ');
|
|
56
|
+
return text.length > max ? `${text.slice(0, max - 1)}...` : text;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function elementLabel(element) {
|
|
60
|
+
const tag = element.tagName.toLowerCase();
|
|
61
|
+
const named =
|
|
62
|
+
element.getAttribute('data-layer-name') ||
|
|
63
|
+
element.getAttribute('data-name') ||
|
|
64
|
+
element.getAttribute('aria-label') ||
|
|
65
|
+
element.getAttribute('alt') ||
|
|
66
|
+
shortText(element.textContent, 48);
|
|
67
|
+
return named ? `${tag}: ${named}` : tag;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cssPath(element, root) {
|
|
71
|
+
if (!element || element === root) return '';
|
|
72
|
+
const parts = [];
|
|
73
|
+
let node = element;
|
|
74
|
+
while (node && node !== root && node.nodeType === 1) {
|
|
75
|
+
const tag = node.tagName.toLowerCase();
|
|
76
|
+
const id = node.getAttribute('id');
|
|
77
|
+
if (id) {
|
|
78
|
+
parts.unshift(`${tag}#${cssEscape(id)}`);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
const className = node.getAttribute('class');
|
|
82
|
+
const classes = className ? className.split(/\s+/).filter(Boolean).slice(0, 2).map(cssEscape) : [];
|
|
83
|
+
const classPart = classes.length ? `.${classes.join('.')}` : '';
|
|
84
|
+
const siblings = Array.from(node.parentElement?.children || []).filter((child) => child.tagName === node.tagName);
|
|
85
|
+
const indexPart = siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(node) + 1})` : '';
|
|
86
|
+
parts.unshift(`${tag}${classPart}${indexPart}`);
|
|
87
|
+
node = node.parentElement;
|
|
88
|
+
}
|
|
89
|
+
return parts.join(' > ');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractComments(html, filePath) {
|
|
93
|
+
const dom = new JSDOM(html);
|
|
94
|
+
const document = dom.window.document;
|
|
95
|
+
const slides = Array.from(document.querySelectorAll('section.slide'));
|
|
96
|
+
const commented = Array.from(document.querySelectorAll('[data-html2pptx-comment]'));
|
|
97
|
+
return commented
|
|
98
|
+
.map((element, index) => {
|
|
99
|
+
const comment = shortText(element.getAttribute('data-html2pptx-comment'), 2000);
|
|
100
|
+
if (!comment) return null;
|
|
101
|
+
const slideElement = element.closest('section.slide');
|
|
102
|
+
const slideIndex = slideElement ? slides.indexOf(slideElement) : -1;
|
|
103
|
+
const root = slideElement || document.body;
|
|
104
|
+
const selector = element.getAttribute('data-html2pptx-comment-selector') || cssPath(element, root) || '__root__';
|
|
105
|
+
return {
|
|
106
|
+
index: index + 1,
|
|
107
|
+
file: filePath,
|
|
108
|
+
slide: slideIndex >= 0 ? slideIndex + 1 : null,
|
|
109
|
+
id: element.getAttribute('data-html2pptx-comment-id') || null,
|
|
110
|
+
selector,
|
|
111
|
+
tag: element.tagName.toLowerCase(),
|
|
112
|
+
label: elementLabel(element),
|
|
113
|
+
comment,
|
|
114
|
+
updatedAt: element.getAttribute('data-html2pptx-comment-updated-at') || null,
|
|
115
|
+
};
|
|
116
|
+
})
|
|
117
|
+
.filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatMarkdown(filePath, comments) {
|
|
121
|
+
if (!comments.length) {
|
|
122
|
+
return `No html2pptx comments found in ${filePath}.`;
|
|
123
|
+
}
|
|
124
|
+
const lines = [
|
|
125
|
+
`# html2pptx comments`,
|
|
126
|
+
'',
|
|
127
|
+
`File: \`${filePath}\``,
|
|
128
|
+
`Count: ${comments.length}`,
|
|
129
|
+
'',
|
|
130
|
+
];
|
|
131
|
+
for (const item of comments) {
|
|
132
|
+
lines.push(
|
|
133
|
+
`## ${item.index}. ${item.slide ? `Slide ${item.slide}` : 'Document'} / ${item.label}`,
|
|
134
|
+
'',
|
|
135
|
+
`- selector: \`${item.selector}\``,
|
|
136
|
+
item.id ? `- id: \`${item.id}\`` : null,
|
|
137
|
+
item.updatedAt ? `- updated: ${item.updatedAt}` : null,
|
|
138
|
+
'',
|
|
139
|
+
item.comment,
|
|
140
|
+
'',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return lines.filter((line) => line !== null).join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function main() {
|
|
147
|
+
const { help, file, format } = parseArgs(process.argv);
|
|
148
|
+
if (help) {
|
|
149
|
+
console.log(usage());
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!file) {
|
|
153
|
+
throw new Error(`Missing file.\n\n${usage()}`);
|
|
154
|
+
}
|
|
155
|
+
const filePath = resolve(file);
|
|
156
|
+
const ext = extname(filePath).toLowerCase();
|
|
157
|
+
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
158
|
+
throw new Error('Only .html and .htm files are supported.');
|
|
159
|
+
}
|
|
160
|
+
const html = await readFile(filePath, 'utf8');
|
|
161
|
+
const comments = extractComments(html, filePath);
|
|
162
|
+
if (format === 'json') {
|
|
163
|
+
console.log(JSON.stringify({ file: filePath, count: comments.length, comments }, null, 2));
|
|
164
|
+
} else {
|
|
165
|
+
console.log(formatMarkdown(filePath, comments));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
main().catch((error) => {
|
|
170
|
+
console.error(error?.message || String(error));
|
|
171
|
+
process.exitCode = 1;
|
|
172
|
+
});
|
package/scripts/install-mcp.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
6
|
|
|
7
7
|
const REMOTE_SERVER_NAME = 'html2pptx';
|
|
8
8
|
const LOCAL_SERVER_NAME = 'html2pptx-local';
|
|
@@ -13,6 +13,7 @@ const args = process.argv.slice(2);
|
|
|
13
13
|
const dryRun = args.includes('--dry-run');
|
|
14
14
|
const help = args.includes('--help') || args.includes('-h');
|
|
15
15
|
const client = resolveClient(args);
|
|
16
|
+
const localCommand = buildLocalMcpCommand();
|
|
16
17
|
|
|
17
18
|
if (help) {
|
|
18
19
|
printHelp();
|
|
@@ -29,7 +30,7 @@ const steps = buildSteps(client);
|
|
|
29
30
|
|
|
30
31
|
console.log(`Installing html2pptx MCP for ${client}.`);
|
|
31
32
|
console.log(`Remote server: ${REMOTE_SERVER_NAME} -> ${REMOTE_MCP_URL}`);
|
|
32
|
-
console.log(`Local server: ${LOCAL_SERVER_NAME} ->
|
|
33
|
+
console.log(`Local server: ${LOCAL_SERVER_NAME} -> ${renderCommand(localCommand.command, localCommand.args)}`);
|
|
33
34
|
console.log('');
|
|
34
35
|
|
|
35
36
|
for (const step of steps) {
|
|
@@ -89,11 +90,8 @@ function buildSteps(targetClient) {
|
|
|
89
90
|
'add',
|
|
90
91
|
LOCAL_SERVER_NAME,
|
|
91
92
|
'--',
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
'-p',
|
|
95
|
-
LOCAL_PACKAGE_SPEC,
|
|
96
|
-
'html2pptx-mcp',
|
|
93
|
+
localCommand.command,
|
|
94
|
+
...localCommand.args,
|
|
97
95
|
],
|
|
98
96
|
},
|
|
99
97
|
];
|
|
@@ -103,8 +101,8 @@ function writeClaudeLocalMcpConfig() {
|
|
|
103
101
|
const configPath = join(process.env.HOME || process.cwd(), '.claude.json');
|
|
104
102
|
const serverConfig = {
|
|
105
103
|
type: 'stdio',
|
|
106
|
-
command:
|
|
107
|
-
args:
|
|
104
|
+
command: localCommand.command,
|
|
105
|
+
args: localCommand.args,
|
|
108
106
|
env: {},
|
|
109
107
|
};
|
|
110
108
|
|
|
@@ -135,6 +133,27 @@ function writeClaudeLocalMcpConfig() {
|
|
|
135
133
|
console.log(`Registered local MCP server ${LOCAL_SERVER_NAME} in Claude user config.`);
|
|
136
134
|
}
|
|
137
135
|
|
|
136
|
+
export function buildLocalMcpCommand(env = process.env) {
|
|
137
|
+
const npmExecPath = env.npm_execpath || env.npm_execPath;
|
|
138
|
+
const npxCli = npmExecPath ? join(dirname(npmExecPath), 'npx-cli.js') : '';
|
|
139
|
+
|
|
140
|
+
if (npxCli && existsSync(npxCli)) {
|
|
141
|
+
return {
|
|
142
|
+
command: process.execPath,
|
|
143
|
+
args: [npxCli, '--yes', '--package', LOCAL_PACKAGE_SPEC, 'html2pptx-mcp'],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
command: 'npx',
|
|
149
|
+
args: ['--yes', '--package', LOCAL_PACKAGE_SPEC, 'html2pptx-mcp'],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderCommand(command, args) {
|
|
154
|
+
return [command, ...args].join(' ');
|
|
155
|
+
}
|
|
156
|
+
|
|
138
157
|
function runStep(step) {
|
|
139
158
|
const rendered = [step.command, ...step.args].join(' ');
|
|
140
159
|
console.log(`> ${rendered}`);
|
|
@@ -179,8 +198,8 @@ Usage:
|
|
|
179
198
|
html2pptx-install-mcp --client codex
|
|
180
199
|
|
|
181
200
|
Examples:
|
|
182
|
-
npx
|
|
183
|
-
npx
|
|
201
|
+
npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp claude
|
|
202
|
+
npx --yes --package html2pptx-local-mcp@latest html2pptx-install-mcp codex
|
|
184
203
|
|
|
185
204
|
Optional:
|
|
186
205
|
HTML2PPTX_LOCAL_MCP_PACKAGE_SPEC=<package-or-tarball> html2pptx-install-mcp claude
|