finch-markdown-editor 0.1.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/dist/index.js ADDED
@@ -0,0 +1,750 @@
1
+ // src/index.ts
2
+ import { createHash } from "node:crypto";
3
+ import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
4
+ import { watch } from "node:fs";
5
+ import { spawn } from "node:child_process";
6
+ import path from "node:path";
7
+ import os from "node:os";
8
+ var PASTE_IMAGE_EXT = {
9
+ "image/png": "png",
10
+ "image/jpeg": "jpg",
11
+ "image/jpg": "jpg",
12
+ "image/gif": "gif",
13
+ "image/webp": "webp",
14
+ "image/svg+xml": "svg",
15
+ "image/bmp": "bmp"
16
+ };
17
+ var IMAGE_MIME_BY_EXT = {
18
+ ".png": "image/png",
19
+ ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg",
21
+ ".gif": "image/gif",
22
+ ".webp": "image/webp",
23
+ ".svg": "image/svg+xml",
24
+ ".bmp": "image/bmp"
25
+ };
26
+ var MAX_CLIPBOARD_INLINE_IMAGE_BYTES = 20 * 1024 * 1024;
27
+ async function readClipboardImageDataUrls(ctx, urls) {
28
+ const result2 = {};
29
+ const assetsRoot = await realpath(path.join(ctx.storagePath, "assets"));
30
+ for (const originalUrl of urls.slice(0, 30)) {
31
+ try {
32
+ const parsed = new URL(originalUrl);
33
+ const requested = parsed.protocol === "finch-file:" && parsed.hostname === "local" ? parsed.searchParams.get("path") : null;
34
+ if (!requested) continue;
35
+ const target = await realpath(requested);
36
+ const relative = path.relative(assetsRoot, target);
37
+ const mimeType = IMAGE_MIME_BY_EXT[path.extname(target).toLowerCase()];
38
+ if ((!relative || !relative.startsWith(".." + path.sep) && relative !== "..") && mimeType) {
39
+ const info = await stat(target);
40
+ if (info.size > MAX_CLIPBOARD_INLINE_IMAGE_BYTES) continue;
41
+ result2[originalUrl] = `data:${mimeType};base64,${(await readFile(target)).toString("base64")}`;
42
+ }
43
+ } catch {
44
+ }
45
+ }
46
+ return result2;
47
+ }
48
+ async function openLocalImagePreview(ctx, filePath) {
49
+ if (!path.isAbsolute(filePath) || !IMAGE_MIME_BY_EXT[path.extname(filePath).toLowerCase()]) {
50
+ throw new Error("Unsupported local image file.");
51
+ }
52
+ await ctx.ui.openFilePreview(filePath);
53
+ }
54
+ async function openMarkdownImagePreview(ctx, rawUrl) {
55
+ const url = new URL(rawUrl);
56
+ if (url.protocol === "http:" || url.protocol === "https:") {
57
+ await ctx.browser.open(url.href);
58
+ return;
59
+ }
60
+ throw new Error(`Unsupported image URL: ${url.protocol}`);
61
+ }
62
+ var STYLE_SLOT_COUNT = 3;
63
+ function result(message, isError = false) {
64
+ return { content: [{ type: "text", text: message }], isError };
65
+ }
66
+ function documentTitle(markdown, filePath) {
67
+ const heading = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
68
+ return heading || (filePath ? path.basename(filePath, path.extname(filePath)) : "Untitled article");
69
+ }
70
+ function stateFile(ctx) {
71
+ return path.join(ctx.storagePath, "state.json");
72
+ }
73
+ function styleSlotsFile(ctx) {
74
+ return path.join(ctx.storagePath, "style-slots.json");
75
+ }
76
+ function normalizeStyleSlots(raw) {
77
+ const arr = Array.isArray(raw) ? raw : [];
78
+ const slots = [];
79
+ for (let i = 0; i < STYLE_SLOT_COUNT; i++) {
80
+ const item = arr[i];
81
+ if (item && typeof item === "object" && typeof item.css === "string") {
82
+ slots.push({ css: item.css, label: String(item.label ?? "\u81EA\u5B9A\u4E49\u98CE\u683C") });
83
+ } else {
84
+ slots.push(null);
85
+ }
86
+ }
87
+ return slots;
88
+ }
89
+ async function readStyleSlots(ctx) {
90
+ try {
91
+ const raw = await readFile(styleSlotsFile(ctx), "utf8");
92
+ return normalizeStyleSlots(JSON.parse(raw));
93
+ } catch {
94
+ return normalizeStyleSlots([]);
95
+ }
96
+ }
97
+ async function writeStyleSlot(ctx, slot, value) {
98
+ const slots = await readStyleSlots(ctx);
99
+ slots[slot] = value;
100
+ await mkdir(ctx.storagePath, { recursive: true });
101
+ await writeFile(styleSlotsFile(ctx), JSON.stringify(slots), "utf8");
102
+ return slots;
103
+ }
104
+ function sessionBucketKey(panel) {
105
+ return panel.sessionId || "__global__";
106
+ }
107
+ async function readLastPathState(ctx) {
108
+ try {
109
+ const raw = await readFile(stateFile(ctx), "utf8");
110
+ return JSON.parse(raw);
111
+ } catch {
112
+ return {};
113
+ }
114
+ }
115
+ async function rememberLastPath(ctx, panel, sourcePath) {
116
+ try {
117
+ await mkdir(ctx.storagePath, { recursive: true });
118
+ const state = await readLastPathState(ctx);
119
+ const legacyPaths = [...Object.values(state.panels ?? {}), ...Object.values(state.sessions ?? {})];
120
+ state.recentPaths = [sourcePath, ...state.recentPaths ?? [], ...legacyPaths].filter((value, index, values) => path.isAbsolute(value) && values.indexOf(value) === index).slice(0, 50);
121
+ state.panels = { ...state.panels, [panel.id]: sourcePath };
122
+ state.sessions = { ...state.sessions, [sessionBucketKey(panel)]: sourcePath };
123
+ await writeFile(stateFile(ctx), JSON.stringify(state), "utf8");
124
+ } catch (error) {
125
+ ctx.logger.warn(`Could not persist last-opened path: ${String(error)}`);
126
+ }
127
+ }
128
+ async function readLastPath(ctx, panel) {
129
+ const state = await readLastPathState(ctx);
130
+ const perPanel = state.panels?.[panel.id];
131
+ if (typeof perPanel === "string") return perPanel;
132
+ const key = sessionBucketKey(panel);
133
+ const perSession = state.sessions?.[key];
134
+ if (typeof perSession === "string") return perSession;
135
+ if (key === "__global__" && typeof state.lastPath === "string") return state.lastPath;
136
+ return void 0;
137
+ }
138
+ var RECENT_LIMIT = 50;
139
+ var RECENT_PREVIEW_CHARS = 220;
140
+ function isMarkdownPath(filePath) {
141
+ return /\.(md|markdown|mdown|mkd)$/i.test(filePath);
142
+ }
143
+ function isInsideDirectory(filePath, directory) {
144
+ const relative = path.relative(directory, filePath);
145
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
146
+ }
147
+ function deriveTitle(markdown, fallback) {
148
+ for (const line of markdown.split("\n", 60)) {
149
+ const heading = line.match(/^\s{0,3}#{1,6}\s+(.*\S)\s*$/);
150
+ if (heading) return heading[1].slice(0, 80);
151
+ }
152
+ for (const line of markdown.split("\n", 60)) {
153
+ const text = line.trim();
154
+ if (text) return text.replace(/^[>*\-+\s]+/, "").slice(0, 80) || fallback;
155
+ }
156
+ return fallback;
157
+ }
158
+ function derivePreview(markdown) {
159
+ return markdown.replace(/^---\n[\s\S]*?\n---\n/, "").replace(/```[\s\S]*?```/g, " ").replace(/!\[[^\]]*\]\([^)]*\)/g, " ").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/^\s{0,3}#{1,6}\s+/gm, "").replace(/[*_`>~]/g, "").replace(/\s+/g, " ").trim().slice(0, RECENT_PREVIEW_CHARS);
160
+ }
161
+ async function collectRecentDocuments(ctx, cwd) {
162
+ if (!cwd || !path.isAbsolute(cwd)) return [];
163
+ const state = await readLastPathState(ctx);
164
+ const candidates = [
165
+ ...state.recentPaths ?? [],
166
+ ...Object.values(state.panels ?? {}),
167
+ ...Object.values(state.sessions ?? {})
168
+ ].filter(
169
+ (value, index, values) => typeof value === "string" && path.isAbsolute(value) && isMarkdownPath(value) && isInsideDirectory(value, cwd) && values.indexOf(value) === index
170
+ );
171
+ const documents = await Promise.all(
172
+ candidates.map(async (filePath) => {
173
+ try {
174
+ const info = await stat(filePath);
175
+ if (!info.isFile()) return void 0;
176
+ const markdown = await readFile(filePath, "utf8");
177
+ const fileName = path.basename(filePath);
178
+ return {
179
+ path: filePath,
180
+ relativePath: path.relative(cwd, filePath),
181
+ fileName,
182
+ title: deriveTitle(markdown, fileName.replace(/\.[^.]+$/, "")),
183
+ preview: derivePreview(markdown),
184
+ modifiedAt: info.mtimeMs
185
+ };
186
+ } catch {
187
+ return void 0;
188
+ }
189
+ })
190
+ );
191
+ return documents.filter((entry) => Boolean(entry)).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, RECENT_LIMIT);
192
+ }
193
+ var livePanelDocuments = /* @__PURE__ */ new Map();
194
+ async function sendDocument(panel, state) {
195
+ livePanelDocuments.set(panel.id, state);
196
+ await panel.postMessage({ type: "document", ...state });
197
+ }
198
+ function payloadPath(panel) {
199
+ const payload = panel.payload;
200
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return void 0;
201
+ const value = payload.path;
202
+ return typeof value === "string" && path.isAbsolute(value) ? value : void 0;
203
+ }
204
+ async function restoreDocument(ctx, panel) {
205
+ if (!panel.sessionId) return false;
206
+ const sourcePath = await readLastPath(ctx, panel) ?? payloadPath(panel);
207
+ if (!sourcePath) return false;
208
+ try {
209
+ const markdown = await readFile(sourcePath, "utf8");
210
+ watchSource(ctx, panel, sourcePath);
211
+ await rememberLastPath(ctx, panel, sourcePath);
212
+ await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
213
+ return true;
214
+ } catch (error) {
215
+ ctx.logger.warn(`Could not restore ${sourcePath}: ${String(error)}`);
216
+ return false;
217
+ }
218
+ }
219
+ var bmmdBinPromise;
220
+ async function ensureBmmdBin(ctx, onInstalling) {
221
+ if (!bmmdBinPromise) {
222
+ bmmdBinPromise = (async () => {
223
+ const installDir = path.join(ctx.storagePath, "bmmd");
224
+ const binPath = path.join(installDir, "node_modules", "bmmd", "bin", "bmmd.mjs");
225
+ try {
226
+ await stat(binPath);
227
+ return binPath;
228
+ } catch {
229
+ }
230
+ onInstalling?.();
231
+ await mkdir(installDir, { recursive: true });
232
+ await new Promise((resolve, reject) => {
233
+ const child = spawn("npm", [
234
+ "install",
235
+ "--no-save",
236
+ "--no-audit",
237
+ "--no-fund",
238
+ "--silent",
239
+ "--prefix",
240
+ installDir,
241
+ "bmmd@latest"
242
+ ], { stdio: "ignore" });
243
+ child.on("error", reject);
244
+ child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`npm install bmmd exited with code ${code}`)));
245
+ });
246
+ await stat(binPath);
247
+ return binPath;
248
+ })();
249
+ }
250
+ try {
251
+ return await bmmdBinPromise;
252
+ } catch (error) {
253
+ bmmdBinPromise = void 0;
254
+ throw error;
255
+ }
256
+ }
257
+ async function runBmmd(ctx, args, input, onInstalling) {
258
+ const binPath = await ensureBmmdBin(ctx, onInstalling);
259
+ return new Promise((resolve, reject) => {
260
+ const child = spawn(process.execPath, [binPath, ...args], { stdio: ["pipe", "pipe", "pipe"] });
261
+ let output = "";
262
+ let errors = "";
263
+ child.stdout.setEncoding("utf8");
264
+ child.stderr.setEncoding("utf8");
265
+ child.stdout.on("data", (chunk) => {
266
+ output += chunk;
267
+ });
268
+ child.stderr.on("data", (chunk) => {
269
+ errors += chunk;
270
+ });
271
+ child.on("error", reject);
272
+ child.on("close", (code) => {
273
+ if (code === 0) resolve(output);
274
+ else reject(new Error(errors.trim() || `bmmd exited with code ${code}`));
275
+ });
276
+ child.stdin.end(input, "utf8");
277
+ });
278
+ }
279
+ var FINCH_FILE_IMAGE_RE = /finch-file:\/\/local\?path=[^\s)"']+/g;
280
+ var FINCH_IMAGE_PLACEHOLDER_ORIGIN = "https://finch-local.invalid/markdown-image/";
281
+ function substituteFinchFileImagesForBm(markdown) {
282
+ const urls = /* @__PURE__ */ new Map();
283
+ let sequence = 0;
284
+ const substituted = markdown.replace(FINCH_FILE_IMAGE_RE, (originalUrl) => {
285
+ const placeholder = `${FINCH_IMAGE_PLACEHOLDER_ORIGIN}${createHash("sha256").update(`${originalUrl}:${sequence++}`).digest("hex")}`;
286
+ urls.set(placeholder, originalUrl);
287
+ return placeholder;
288
+ });
289
+ return { markdown: substituted, urls };
290
+ }
291
+ async function renderWithBm(ctx, markdown, markdownStyle, customCss, onInstalling) {
292
+ const args = ["render", "--platform", "wechat", "--markdown-style", markdownStyle || "kami"];
293
+ if (customCss && customCss.trim()) args.push("--custom-css", customCss);
294
+ const prepared = substituteFinchFileImagesForBm(markdown);
295
+ let html = await runBmmd(ctx, args, prepared.markdown, onInstalling);
296
+ for (const [placeholder, originalUrl] of prepared.urls) html = html.split(placeholder).join(originalUrl);
297
+ return html;
298
+ }
299
+ var panelWatchers = /* @__PURE__ */ new Map();
300
+ var lastPanel;
301
+ function stopWatching(panelId) {
302
+ const entry = panelWatchers.get(panelId);
303
+ if (entry) {
304
+ entry.watcher.close();
305
+ if (entry.timer) clearTimeout(entry.timer);
306
+ }
307
+ panelWatchers.delete(panelId);
308
+ }
309
+ function watchSource(ctx, panel, sourcePath) {
310
+ stopWatching(panel.id);
311
+ try {
312
+ const entry = { watcher: void 0 };
313
+ entry.watcher = watch(sourcePath, () => {
314
+ if (entry.timer) clearTimeout(entry.timer);
315
+ entry.timer = setTimeout(async () => {
316
+ try {
317
+ const markdown = await readFile(sourcePath, "utf8");
318
+ await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
319
+ } catch (error) {
320
+ ctx.logger.warn(`Source refresh failed: ${String(error)}`);
321
+ }
322
+ }, 150);
323
+ });
324
+ panelWatchers.set(panel.id, entry);
325
+ } catch (error) {
326
+ ctx.logger.warn(`Could not watch ${sourcePath}: ${String(error)}`);
327
+ }
328
+ }
329
+ var cachedAssistantName;
330
+ async function getAssistantName(ctx) {
331
+ if (cachedAssistantName) return cachedAssistantName;
332
+ try {
333
+ cachedAssistantName = (await ctx.app.getInfo()).assistantName || "Finch";
334
+ } catch (error) {
335
+ ctx.logger.warn(`Failed to resolve assistant name: ${String(error)}`);
336
+ cachedAssistantName = "Finch";
337
+ }
338
+ return cachedAssistantName;
339
+ }
340
+ async function sendReady(ctx, panel) {
341
+ const pickFileSupported = ctx.api.supports("ui.pickFile");
342
+ const styleSlots = await readStyleSlots(ctx);
343
+ const assistantName = await getAssistantName(ctx);
344
+ ctx.logger.info(`sending ready to panel; pickFileSupported = ${pickFileSupported}`);
345
+ await panel.postMessage({
346
+ type: "ready",
347
+ locale: ctx.i18n.locale,
348
+ pickFileSupported,
349
+ styleSlots,
350
+ assistantName,
351
+ // So the page can render `cwd` the OS-friendly way (`~/…`) without a
352
+ // round trip — it never needs the raw value for anything but display.
353
+ homeDir: os.homedir()
354
+ });
355
+ }
356
+ function revealInFileManager(ctx, directory) {
357
+ try {
358
+ if (process.platform === "darwin") spawn("open", [directory], { stdio: "ignore", detached: true }).unref();
359
+ else if (process.platform === "win32") spawn("explorer", [directory], { stdio: "ignore", detached: true }).unref();
360
+ else spawn("xdg-open", [directory], { stdio: "ignore", detached: true }).unref();
361
+ } catch (error) {
362
+ ctx.logger.warn(`Could not open file manager for ${directory}: ${String(error)}`);
363
+ }
364
+ }
365
+ async function handleMessage(ctx, panel, raw) {
366
+ const message = raw;
367
+ switch (message.type) {
368
+ case "clientLog": {
369
+ ctx.logger.info(`[panel] ${String(message.message ?? "")}`);
370
+ return;
371
+ }
372
+ case "panelReady": {
373
+ await sendReady(ctx, panel);
374
+ const liveDocument = livePanelDocuments.get(panel.id);
375
+ if (liveDocument) {
376
+ await sendDocument(panel, liveDocument);
377
+ } else if (!await restoreDocument(ctx, panel)) {
378
+ await panel.postMessage({ type: "lastFileUnavailable" });
379
+ }
380
+ return;
381
+ }
382
+ case "openImage": {
383
+ const filePath = String(message.path ?? "").trim();
384
+ const rawUrl = String(message.url ?? "").trim();
385
+ try {
386
+ if (filePath) await openLocalImagePreview(ctx, filePath);
387
+ else await openMarkdownImagePreview(ctx, rawUrl);
388
+ } catch (error) {
389
+ ctx.logger.warn(`Ignored Markdown image preview: ${error instanceof Error ? error.message : String(error)}`);
390
+ }
391
+ return;
392
+ }
393
+ case "openLink": {
394
+ const rawUrl = String(message.url ?? "").trim();
395
+ let target;
396
+ try {
397
+ target = new URL(rawUrl);
398
+ } catch {
399
+ ctx.logger.warn(`Ignored invalid Markdown link: ${rawUrl}`);
400
+ return;
401
+ }
402
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
403
+ ctx.logger.warn(`Ignored unsupported Markdown link protocol: ${target.protocol}`);
404
+ return;
405
+ }
406
+ await ctx.browser.open(target.href);
407
+ return;
408
+ }
409
+ case "requestOpen": {
410
+ ctx.logger.info("requestOpen received; calling ctx.ui.pickFile()");
411
+ try {
412
+ const handle = ctx.ui.pickFile({
413
+ title: "\u9009\u62E9Markdown\u6587\u4EF6",
414
+ filter: { extensions: [".md", ".markdown"] }
415
+ });
416
+ ctx.logger.info("ctx.ui.pickFile() call returned a handle, awaiting resolution\u2026");
417
+ const picked = await handle;
418
+ ctx.logger.info(`pickFile() resolved: action=${picked.action}, files=${picked.files.length}`);
419
+ if (picked.action !== "select" || picked.files.length === 0) {
420
+ await panel.postMessage({ type: "pickCancelled" });
421
+ return;
422
+ }
423
+ const sourcePath = picked.files[0].path;
424
+ const markdown = await readFile(sourcePath, "utf8");
425
+ watchSource(ctx, panel, sourcePath);
426
+ await rememberLastPath(ctx, panel, sourcePath);
427
+ await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
428
+ } catch (error) {
429
+ ctx.logger.error(`pickFile() threw: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
430
+ await panel.postMessage({
431
+ type: "error",
432
+ fallback: true,
433
+ message: `Native file picker failed, falling back to the browser dialog: ${error instanceof Error ? error.message : String(error)}`
434
+ });
435
+ }
436
+ return;
437
+ }
438
+ case "loadPath": {
439
+ const sourcePath = String(message.path ?? "").trim();
440
+ if (!path.isAbsolute(sourcePath)) {
441
+ await panel.postMessage({ type: "error", message: "Please provide an absolute Markdown path." });
442
+ return;
443
+ }
444
+ try {
445
+ const markdown = await readFile(sourcePath, "utf8");
446
+ watchSource(ctx, panel, sourcePath);
447
+ await rememberLastPath(ctx, panel, sourcePath);
448
+ await sendDocument(panel, { path: sourcePath, markdown, title: documentTitle(markdown, sourcePath) });
449
+ } catch (error) {
450
+ await panel.postMessage({ type: "error", message: `Cannot read file: ${error instanceof Error ? error.message : String(error)}` });
451
+ }
452
+ return;
453
+ }
454
+ case "watchPath": {
455
+ const sourcePath = String(message.path ?? "").trim();
456
+ if (!path.isAbsolute(sourcePath)) return;
457
+ watchSource(ctx, panel, sourcePath);
458
+ await rememberLastPath(ctx, panel, sourcePath);
459
+ await panel.postMessage({ type: "watchStarted", path: sourcePath });
460
+ return;
461
+ }
462
+ case "requestLastFile": {
463
+ const liveDocument = livePanelDocuments.get(panel.id);
464
+ if (liveDocument) {
465
+ await sendDocument(panel, liveDocument);
466
+ } else if (!await restoreDocument(ctx, panel)) {
467
+ await panel.postMessage({ type: "lastFileUnavailable" });
468
+ }
469
+ return;
470
+ }
471
+ case "openPath": {
472
+ const targetPath = String(message.path ?? "").trim();
473
+ if (targetPath && path.isAbsolute(targetPath)) revealInFileManager(ctx, targetPath);
474
+ return;
475
+ }
476
+ case "goHome": {
477
+ stopWatching(panel.id);
478
+ livePanelDocuments.delete(panel.id);
479
+ return;
480
+ }
481
+ case "requestRecentDocuments": {
482
+ const cwd = String(message.cwd ?? "").trim();
483
+ try {
484
+ await panel.postMessage({ type: "recentDocuments", cwd, documents: await collectRecentDocuments(ctx, cwd) });
485
+ } catch (error) {
486
+ ctx.logger.warn(`Could not collect recent documents: ${String(error)}`);
487
+ await panel.postMessage({ type: "recentDocuments", cwd, documents: [] });
488
+ }
489
+ return;
490
+ }
491
+ case "saveMarkdown": {
492
+ const sourcePath = String(message.path ?? "").trim();
493
+ if (!path.isAbsolute(sourcePath)) return;
494
+ try {
495
+ await writeFile(sourcePath, String(message.markdown ?? ""), "utf8");
496
+ await panel.postMessage({ type: "savedMarkdown", path: sourcePath, requestId: message.requestId });
497
+ } catch (error) {
498
+ await panel.postMessage({ type: "error", message: `Could not save Markdown: ${error instanceof Error ? error.message : String(error)}` });
499
+ }
500
+ return;
501
+ }
502
+ case "renderBm": {
503
+ try {
504
+ const html = await renderWithBm(ctx, String(message.markdown ?? ""), String(message.markdownStyle ?? "kami"), message.customCss, () => {
505
+ panel.postMessage({ type: "status", message: "\u9996\u6B21\u6E32\u67D3\uFF1A\u6B63\u5728\u672C\u673A\u5B89\u88C5 bmmd \u6E32\u67D3\u5F15\u64CE\uFF08\u7EA6\u51E0\u79D2\uFF0C\u4EC5\u4E00\u6B21\uFF09\u2026" }).catch(() => {
506
+ });
507
+ });
508
+ await panel.postMessage({ type: "bmRendered", html, requestId: message.requestId });
509
+ } catch (error) {
510
+ await panel.postMessage({ type: "error", message: `bm.md rendering failed: ${error instanceof Error ? error.message : String(error)}` });
511
+ }
512
+ return;
513
+ }
514
+ case "updateToolbar": {
515
+ const itemId = String(message.itemId ?? "").trim();
516
+ if (!itemId || !message.patch) return;
517
+ try {
518
+ await panel.updateToolbarItem(itemId, message.patch);
519
+ } catch (error) {
520
+ ctx.logger.warn(`Could not update toolbar item ${itemId}: ${String(error)}`);
521
+ }
522
+ return;
523
+ }
524
+ case "setToolbar": {
525
+ if (!Array.isArray(message.toolbar)) return;
526
+ try {
527
+ await panel.setToolbar(message.toolbar);
528
+ } catch (error) {
529
+ ctx.logger.warn(`Could not replace toolbar: ${String(error)}`);
530
+ }
531
+ return;
532
+ }
533
+ case "applyReplacement": {
534
+ const sourcePath = String(message.path ?? "").trim();
535
+ const markdown = String(message.markdown ?? "");
536
+ if (!sourcePath || !path.isAbsolute(sourcePath)) {
537
+ await panel.postMessage({ type: "error", message: "Pasted documents can be revised in the editor, but source-file apply needs an absolute path." });
538
+ return;
539
+ }
540
+ try {
541
+ await writeFile(sourcePath, markdown, "utf8");
542
+ await panel.postMessage({ type: "applied", path: sourcePath, title: documentTitle(markdown, sourcePath) });
543
+ } catch (error) {
544
+ await panel.postMessage({ type: "error", message: `Could not apply revision: ${error instanceof Error ? error.message : String(error)}` });
545
+ }
546
+ return;
547
+ }
548
+ case "readClipboardImages": {
549
+ try {
550
+ const images = await readClipboardImageDataUrls(ctx, Array.isArray(message.urls) ? message.urls.filter((url) => typeof url === "string") : []);
551
+ await panel.postMessage({ type: "clipboardImages", requestId: message.requestId, images });
552
+ } catch (error) {
553
+ await panel.postMessage({ type: "clipboardImagesError", requestId: message.requestId, message: error instanceof Error ? error.message : String(error) });
554
+ }
555
+ return;
556
+ }
557
+ case "pasteImage": {
558
+ const dataBase64 = String(message.data ?? "");
559
+ if (!dataBase64) {
560
+ await panel.postMessage({ type: "pasteImageError", requestId: message.requestId, message: "Empty image data." });
561
+ return;
562
+ }
563
+ try {
564
+ const ext = PASTE_IMAGE_EXT[String(message.mimeType ?? "").toLowerCase()] ?? "png";
565
+ const buffer = Buffer.from(dataBase64, "base64");
566
+ const digest = createHash("sha256").update(buffer).digest("hex").slice(0, 20);
567
+ const dir = path.join(ctx.storagePath, "assets");
568
+ await mkdir(dir, { recursive: true });
569
+ const targetPath = path.join(dir, `${digest}.${ext}`);
570
+ const alreadyExists = await stat(targetPath).then(() => true).catch(() => false);
571
+ if (!alreadyExists) await writeFile(targetPath, buffer);
572
+ const url = `finch-file://local?path=${encodeURIComponent(targetPath)}`;
573
+ await panel.postMessage({ type: "pastedImage", requestId: message.requestId, url, path: targetPath });
574
+ } catch (error) {
575
+ await panel.postMessage({ type: "pasteImageError", requestId: message.requestId, message: error instanceof Error ? error.message : String(error) });
576
+ }
577
+ return;
578
+ }
579
+ case "saveStyleSlot": {
580
+ const slot = Number(message.slot);
581
+ if (!Number.isInteger(slot) || slot < 0 || slot >= STYLE_SLOT_COUNT) return;
582
+ const css = String(message.css ?? "").trim();
583
+ if (!css) {
584
+ await panel.postMessage({ type: "error", message: "Nothing to save: the active style has no custom CSS." });
585
+ return;
586
+ }
587
+ try {
588
+ const slots = await writeStyleSlot(ctx, slot, { css, label: String(message.label ?? "").trim() || "\u81EA\u5B9A\u4E49\u98CE\u683C" });
589
+ await panel.postMessage({ type: "styleSlots", styleSlots: slots, savedSlot: slot });
590
+ } catch (error) {
591
+ await panel.postMessage({ type: "error", message: `Could not save style slot: ${error instanceof Error ? error.message : String(error)}` });
592
+ }
593
+ return;
594
+ }
595
+ case "exportFile": {
596
+ const dataBase64 = String(message.data ?? "");
597
+ const ext = String(message.ext ?? "bin").replace(/[^a-z0-9]/gi, "") || "bin";
598
+ if (!dataBase64) {
599
+ await panel.postMessage({ type: "error", message: "Nothing to export." });
600
+ return;
601
+ }
602
+ try {
603
+ const sourcePath = String(message.path ?? "").trim();
604
+ let dir = path.isAbsolute(sourcePath) ? path.dirname(sourcePath) : "";
605
+ if (!dir) {
606
+ const downloads = path.join(os.homedir(), "Downloads");
607
+ dir = await stat(downloads).then((s) => s.isDirectory()).catch(() => false) ? downloads : ctx.storagePath;
608
+ }
609
+ const rawName = String(message.fileName ?? "").trim() || documentTitle(String(message.markdown ?? ""), sourcePath || void 0);
610
+ const safeName = rawName.replace(/[\\/:*?"<>|]/g, " ").trim().slice(0, 120) || "article";
611
+ const targetPath = path.join(dir, `${safeName}.${ext}`);
612
+ await writeFile(targetPath, Buffer.from(dataBase64, "base64"));
613
+ await panel.postMessage({ type: "exported", path: targetPath, requestId: message.requestId });
614
+ } catch (error) {
615
+ await panel.postMessage({ type: "error", message: `Could not export file: ${error instanceof Error ? error.message : String(error)}` });
616
+ }
617
+ return;
618
+ }
619
+ }
620
+ }
621
+ function activate(ctx) {
622
+ ctx.subscriptions.push(ctx.icons.register("markdown-editor-icons", {
623
+ "folder-open": {
624
+ svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>'
625
+ },
626
+ save: {
627
+ svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>'
628
+ },
629
+ "save-check": {
630
+ svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.5 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h10.2a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4v4.35"/><path d="m16 19 2 2 4-4"/><path d="M17 15.13V14a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>'
631
+ },
632
+ "file-pen-line": {
633
+ svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.364 13.634a2 2 0 0 0-.506.854l-.837 2.87a.5.5 0 0 0 .62.62l2.87-.837a2 2 0 0 0 .854-.506l4.013-4.009a1 1 0 0 0-3.004-3.004z"/><path d="M14.487 7.858A1 1 0 0 1 14 7V2"/><path d="M20 19.645V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l2.516 2.516"/><path d="M8 18h1"/></svg>'
634
+ },
635
+ type: {
636
+ svg: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-type-icon lucide-type"><path d="M12 4v16"/><path d="M4 7V5a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v2"/><path d="M9 20h6"/></svg>'
637
+ },
638
+ "wechat-copy": {
639
+ svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path stroke-linecap="round" stroke-width="2" d="M7 7h.009m5.982 0H13m4.991 7.5H18m-4 0h.009"></path><path stroke-width="2" d="M10 16c0 2.761 2.686 5 6 5c.907 0 1.767-.168 2.538-.468c.189-.073.393-.1.592-.063L22 21l-.652-2.03a1.13 1.13 0 0 1 .11-.89A4.3 4.3 0 0 0 22 16c0-2.761-2.686-5-6-5s-6 2.239-6 5Z"></path><path stroke-width="2" d="M17.873 11.249Q18 10.639 18 10c0-3.866-3.582-7-8-7s-8 3.134-8 7c0 1.112.297 2.164.824 3.098c.147.26.196.567.108.853L2 17l3.914-.76c.208-.041.422-.013.617.07a9 9 0 0 0 3.589.69"></path></svg>'
640
+ }
641
+ }));
642
+ ctx.subscriptions.push(ctx.ui.onDidOpenPanel((panel) => {
643
+ if (panel.visible) lastPanel = panel;
644
+ ctx.subscriptions.push(panel.onDidReceiveMessage((message) => {
645
+ handleMessage(ctx, panel, message).catch((error) => {
646
+ ctx.logger.warn(`handleMessage failed for '${message?.type}': ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
647
+ });
648
+ }));
649
+ ctx.subscriptions.push(panel.onDidChangeVisibility((visible) => {
650
+ if (visible) lastPanel = panel;
651
+ else if (lastPanel === panel) lastPanel = void 0;
652
+ }));
653
+ ctx.subscriptions.push(panel.onDidDispose(() => {
654
+ stopWatching(panel.id);
655
+ livePanelDocuments.delete(panel.id);
656
+ if (lastPanel === panel) lastPanel = void 0;
657
+ }));
658
+ void sendReady(ctx, panel).catch((error) => ctx.logger.warn(String(error)));
659
+ }));
660
+ ctx.subscriptions.push(ctx.tools.register({
661
+ name: "markdown_editor_document",
662
+ title: "Markdown \u7F16\u8F91",
663
+ description: `Open, create, revise, or restyle a Markdown document in Markdown Editor.
664
+ action:
665
+ open \u2014 read an absolute local Markdown path and open it as an editable WeChat article preview
666
+ create \u2014 write brand-new Markdown content to an absolute path that does not exist yet, then open it in Markdown Editor. Use this whenever the user asks to create/draft a new Markdown file (Markdown Editor's own UI has no "new file" button on purpose \u2014 this tool action is the intended way to start a new document)
667
+ apply \u2014 replace a source document with reviewed Markdown (requires path and markdown); the open panel refreshes in place, no Diff window
668
+ set_style \u2014 apply an AI-designed custom CSS layout to the currently open Markdown Editor preview (requires css). Write plain CSS scoped under #bm-md using tag/id selectors (no classes), use !important where needed to override the base style, and take inspiration from bm.md's built-in styles: kami (warm paper), bauhaus (geometric primary colors), blueprint (technical grid), botanical (soft green), newsprint (editorial serif), retro (nostalgic), sketch (hand-drawn), terminal (monospace dark).`,
669
+ inputSchema: {
670
+ type: "object",
671
+ properties: {
672
+ action: { type: "string", enum: ["open", "create", "apply", "set_style"], description: "Operation to perform." },
673
+ path: { type: "string", description: "Absolute path to the Markdown file. Required for open, create, and apply. For create, the file must not already exist." },
674
+ markdown: { type: "string", description: "Full Markdown content, required for create and apply." },
675
+ css: { type: "string", description: "Custom CSS to layer on top of the current base style, required for set_style." },
676
+ label: { type: "string", description: "Short label describing the custom style, optional for set_style." },
677
+ slot: { type: "number", enum: [1, 2, 3], description: "Required for AI-designed styles: user-selected reusable custom style slot to overwrite." }
678
+ },
679
+ required: ["action"]
680
+ },
681
+ risk: "medium",
682
+ async execute(input) {
683
+ const action = String(input.action ?? "");
684
+ if (action === "open" || action === "create" || action === "apply") {
685
+ const sourcePath = String(input.path ?? "").trim();
686
+ if (!path.isAbsolute(sourcePath)) return result("`path` must be an absolute local path.", true);
687
+ if (action === "open") {
688
+ try {
689
+ const markdown2 = await readFile(sourcePath, "utf8");
690
+ const panel = ctx.ui.createPanel({ instanceMode: "single", payload: { path: sourcePath } });
691
+ await panel.reveal();
692
+ watchSource(ctx, panel, sourcePath);
693
+ await rememberLastPath(ctx, panel, sourcePath);
694
+ await sendDocument(panel, { path: sourcePath, markdown: markdown2, title: documentTitle(markdown2, sourcePath) });
695
+ return result(`Opened Markdown Editor for ${path.basename(sourcePath)}.`);
696
+ } catch (error) {
697
+ return result(`Could not read ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`, true);
698
+ }
699
+ }
700
+ if (action === "create") {
701
+ const markdown2 = String(input.markdown ?? "");
702
+ try {
703
+ const alreadyExists = await stat(sourcePath).then(() => true).catch(() => false);
704
+ if (alreadyExists) return result(`${sourcePath} already exists. Use action 'open' to view it or 'apply' to revise it instead.`, true);
705
+ await mkdir(path.dirname(sourcePath), { recursive: true });
706
+ await writeFile(sourcePath, markdown2, "utf8");
707
+ const panel = ctx.ui.createPanel({ instanceMode: "single", payload: { path: sourcePath } });
708
+ await panel.reveal();
709
+ watchSource(ctx, panel, sourcePath);
710
+ await rememberLastPath(ctx, panel, sourcePath);
711
+ await sendDocument(panel, { path: sourcePath, markdown: markdown2, title: documentTitle(markdown2, sourcePath) });
712
+ return result(`Created ${path.basename(sourcePath)} and opened it in Markdown Editor.`);
713
+ } catch (error) {
714
+ return result(`Could not create ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`, true);
715
+ }
716
+ }
717
+ const markdown = String(input.markdown ?? "");
718
+ if (!markdown) return result("`apply` requires non-empty `markdown`.", true);
719
+ try {
720
+ await writeFile(sourcePath, markdown, "utf8");
721
+ return result(`Applied reviewed Markdown to ${path.basename(sourcePath)}.`);
722
+ } catch (error) {
723
+ return result(`Could not apply revision: ${error instanceof Error ? error.message : String(error)}`, true);
724
+ }
725
+ }
726
+ if (action === "set_style") {
727
+ const css = String(input.css ?? "").trim();
728
+ if (!css) return result("`set_style` requires non-empty `css`.", true);
729
+ if (!lastPanel) return result("No Markdown Editor panel is open. Ask the user to open a document first.", true);
730
+ try {
731
+ const label = String(input.label ?? "") || "AI style";
732
+ const slot = Number(input.slot);
733
+ if (!Number.isInteger(slot) || slot < 1 || slot > STYLE_SLOT_COUNT) {
734
+ return result("Ask the user which reusable custom style slot (1, 2, or 3) to overwrite, then call `set_style` again with that `slot`.", true);
735
+ }
736
+ const slots = await writeStyleSlot(ctx, slot - 1, { css, label });
737
+ await lastPanel.postMessage({ type: "customStyleSet", css, label, styleSlots: slots, savedSlot: slot - 1 });
738
+ return result(`Custom style saved to slot ${slot} and applied to the open Markdown Editor panel.`);
739
+ } catch (error) {
740
+ return result(`Could not apply custom style: ${error instanceof Error ? error.message : String(error)}`, true);
741
+ }
742
+ }
743
+ return result(`Unknown action: ${action}`, true);
744
+ }
745
+ }));
746
+ ctx.logger.info("finch-markdown-editor activated");
747
+ }
748
+ export {
749
+ activate
750
+ };