finch-markdown-editor 0.1.7 → 0.1.9

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 CHANGED
@@ -64,6 +64,57 @@ var STYLE_SLOT_COUNT = 3;
64
64
  function result(message, isError = false) {
65
65
  return { content: [{ type: "text", text: message }], isError };
66
66
  }
67
+ function unwrapTextEnvelope(content) {
68
+ const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
69
+ const text = bom ? content.slice(1) : content;
70
+ const firstCrLf = text.indexOf("\r\n");
71
+ const firstLf = text.indexOf("\n");
72
+ const lineEnding = firstCrLf !== -1 && (firstLf === -1 || firstCrLf <= firstLf) ? "\r\n" : "\n";
73
+ return { bom, lineEnding, text: text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") };
74
+ }
75
+ function normalizeToLf(text) {
76
+ return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
77
+ }
78
+ function rewrapTextEnvelope(envelope, normalizedText) {
79
+ const restored = envelope.lineEnding === "\r\n" ? normalizedText.replace(/\n/g, "\r\n") : normalizedText;
80
+ return envelope.bom + restored;
81
+ }
82
+ function preserveTextEnvelope(existingContent, replacement) {
83
+ const envelope = unwrapTextEnvelope(existingContent);
84
+ return rewrapTextEnvelope(envelope, normalizeToLf(replacement.replace(/^\ufeff/, "")));
85
+ }
86
+ function applyEditSpecs(content, edits) {
87
+ const envelope = unwrapTextEnvelope(content);
88
+ let working = envelope.text;
89
+ for (let i = 0; i < edits.length; i++) {
90
+ const { old_string: rawOldString, new_string: rawNewString, replace_all: replaceAll } = edits[i];
91
+ if (typeof rawOldString !== "string" || rawOldString.length === 0) {
92
+ return { ok: false, error: `edits[${i}]: 'old_string' must be a non-empty string.` };
93
+ }
94
+ if (typeof rawNewString !== "string") {
95
+ return { ok: false, error: `edits[${i}]: 'new_string' must be a string.` };
96
+ }
97
+ const oldString = normalizeToLf(rawOldString);
98
+ const newString = normalizeToLf(rawNewString);
99
+ if (oldString === newString) {
100
+ return { ok: false, error: `edits[${i}]: 'old_string' and 'new_string' are identical \u2014 nothing to change.` };
101
+ }
102
+ const occurrences = working.split(oldString).length - 1;
103
+ if (occurrences === 0) {
104
+ return { ok: false, error: `edits[${i}]: 'old_string' was not found in the file's current content. Check the exact text (including whitespace/line breaks) \u2014 the file may differ from what you last saw.` };
105
+ }
106
+ if (occurrences > 1 && !replaceAll) {
107
+ return { ok: false, error: `edits[${i}]: 'old_string' matches ${occurrences} places in the file. Include more surrounding context to make it unique, or set 'replace_all: true' to replace every match.` };
108
+ }
109
+ if (replaceAll) {
110
+ working = working.split(oldString).join(newString);
111
+ } else {
112
+ const index = working.indexOf(oldString);
113
+ working = working.slice(0, index) + newString + working.slice(index + oldString.length);
114
+ }
115
+ }
116
+ return { ok: true, content: rewrapTextEnvelope(envelope, working) };
117
+ }
67
118
  function documentTitle(markdown, filePath) {
68
119
  const heading = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
69
120
  return heading || (filePath ? path.basename(filePath, path.extname(filePath)) : "Untitled article");
@@ -176,19 +227,64 @@ async function readLastPathState(ctx) {
176
227
  return {};
177
228
  }
178
229
  }
230
+ function resolveRecentScope(state, cwd, sessionId, spaceId) {
231
+ if (path.isAbsolute(cwd)) {
232
+ if (sessionId && !spaceId) state.homePath = cwd;
233
+ return { scope: cwd };
234
+ }
235
+ if (!cwd && !sessionId && !spaceId && path.isAbsolute(state.homePath ?? "")) {
236
+ return { scope: state.homePath, fallbackCwd: state.homePath };
237
+ }
238
+ return {};
239
+ }
240
+ function addRecentPath(state, scope, sourcePath) {
241
+ const existing = state.recentPathsByScope?.[scope] ?? [];
242
+ state.recentPathsByScope = {
243
+ ...state.recentPathsByScope,
244
+ [scope]: [sourcePath, ...existing].filter((value, index, values) => path.isAbsolute(value) && values.indexOf(value) === index).slice(0, 50)
245
+ };
246
+ }
247
+ async function rememberPanelRecentScope(ctx, panel, cwd, sessionId, spaceId) {
248
+ try {
249
+ await mkdir(ctx.storagePath, { recursive: true });
250
+ const state = await readLastPathState(ctx);
251
+ const resolved = resolveRecentScope(state, cwd, sessionId, spaceId);
252
+ if (resolved.scope) {
253
+ state.panelRecentScopes = { ...state.panelRecentScopes, [panel.id]: resolved.scope };
254
+ const panelPath = state.panels?.[panel.id];
255
+ if (panelPath) addRecentPath(state, resolved.scope, panelPath);
256
+ }
257
+ await writeFile(stateFile(ctx), JSON.stringify(state), "utf8");
258
+ return resolved;
259
+ } catch (error) {
260
+ ctx.logger.warn(`Could not persist panel recent scope: ${String(error)}`);
261
+ return {};
262
+ }
263
+ }
179
264
  async function rememberLastPath(ctx, panel, sourcePath) {
180
265
  try {
181
266
  await mkdir(ctx.storagePath, { recursive: true });
182
267
  const state = await readLastPathState(ctx);
183
- const legacyPaths = [...Object.values(state.panels ?? {}), ...Object.values(state.sessions ?? {})];
184
- state.recentPaths = [sourcePath, ...state.recentPaths ?? [], ...legacyPaths].filter((value, index, values) => path.isAbsolute(value) && values.indexOf(value) === index).slice(0, 50);
185
268
  state.panels = { ...state.panels, [panel.id]: sourcePath };
186
269
  state.sessions = { ...state.sessions, [sessionBucketKey(panel)]: sourcePath };
270
+ const scope = state.panelRecentScopes?.[panel.id];
271
+ if (scope) addRecentPath(state, scope, sourcePath);
187
272
  await writeFile(stateFile(ctx), JSON.stringify(state), "utf8");
188
273
  } catch (error) {
189
274
  ctx.logger.warn(`Could not persist last-opened path: ${String(error)}`);
190
275
  }
191
276
  }
277
+ async function rememberRecentPath(ctx, sourcePath, panel) {
278
+ try {
279
+ await mkdir(ctx.storagePath, { recursive: true });
280
+ const state = await readLastPathState(ctx);
281
+ const scope = panel ? state.panelRecentScopes?.[panel.id] : resolveRecentScope(state, ctx.session.cwd ?? "", ctx.session.id ?? "", ctx.session.spaceId ?? "").scope;
282
+ if (scope) addRecentPath(state, scope, sourcePath);
283
+ await writeFile(stateFile(ctx), JSON.stringify(state), "utf8");
284
+ } catch (error) {
285
+ ctx.logger.warn(`Could not persist recent path: ${String(error)}`);
286
+ }
287
+ }
192
288
  async function readLastPath(ctx, panel) {
193
289
  const state = await readLastPathState(ctx);
194
290
  const perPanel = state.panels?.[panel.id];
@@ -204,10 +300,6 @@ var RECENT_PREVIEW_CHARS = 220;
204
300
  function isMarkdownPath(filePath) {
205
301
  return /\.(md|markdown|mdown|mkd)$/i.test(filePath);
206
302
  }
207
- function isInsideDirectory(filePath, directory) {
208
- const relative = path.relative(directory, filePath);
209
- return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
210
- }
211
303
  function deriveTitle(markdown, fallback) {
212
304
  for (const line of markdown.split("\n", 60)) {
213
305
  const heading = line.match(/^\s{0,3}#{1,6}\s+(.*\S)\s*$/);
@@ -222,15 +314,14 @@ function deriveTitle(markdown, fallback) {
222
314
  function derivePreview(markdown) {
223
315
  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);
224
316
  }
225
- async function collectRecentDocuments(ctx, cwd) {
226
- if (!cwd || !path.isAbsolute(cwd)) return [];
317
+ async function collectRecentDocuments(ctx, requestedCwd, sessionId, spaceId) {
318
+ const cwd = requestedCwd;
227
319
  const state = await readLastPathState(ctx);
228
- const candidates = [
229
- ...state.recentPaths ?? [],
230
- ...Object.values(state.panels ?? {}),
231
- ...Object.values(state.sessions ?? {})
232
- ].filter(
233
- (value, index, values) => typeof value === "string" && path.isAbsolute(value) && isMarkdownPath(value) && isInsideDirectory(value, cwd) && values.indexOf(value) === index
320
+ const resolved = resolveRecentScope(state, cwd, sessionId, spaceId);
321
+ if (!resolved.scope) return { documents: [] };
322
+ const scope = resolved.scope;
323
+ const candidates = (state.recentPathsByScope?.[scope] ?? []).filter(
324
+ (value, index, values) => typeof value === "string" && path.isAbsolute(value) && isMarkdownPath(value) && values.indexOf(value) === index
234
325
  );
235
326
  const documents = await Promise.all(
236
327
  candidates.map(async (filePath) => {
@@ -241,7 +332,7 @@ async function collectRecentDocuments(ctx, cwd) {
241
332
  const fileName = path.basename(filePath);
242
333
  return {
243
334
  path: filePath,
244
- relativePath: path.relative(cwd, filePath),
335
+ relativePath: path.relative(scope, filePath),
245
336
  fileName,
246
337
  title: deriveTitle(markdown, fileName.replace(/\.[^.]+$/, "")),
247
338
  preview: derivePreview(markdown),
@@ -252,7 +343,10 @@ async function collectRecentDocuments(ctx, cwd) {
252
343
  }
253
344
  })
254
345
  );
255
- return documents.filter((entry) => Boolean(entry)).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, RECENT_LIMIT);
346
+ return {
347
+ documents: documents.filter((entry) => Boolean(entry)).sort((a, b) => b.modifiedAt - a.modifiedAt).slice(0, RECENT_LIMIT),
348
+ fallbackCwd: resolved.fallbackCwd
349
+ };
256
350
  }
257
351
  var livePanelDocuments = /* @__PURE__ */ new Map();
258
352
  async function sendDocument(panel, state) {
@@ -530,8 +624,12 @@ async function handleMessage(ctx, panel, raw) {
530
624
  }
531
625
  case "requestRecentDocuments": {
532
626
  const cwd = String(message.cwd ?? "").trim();
627
+ const sessionId = String(message.sessionId ?? "").trim();
628
+ const spaceId = String(message.spaceId ?? "").trim();
533
629
  try {
534
- await panel.postMessage({ type: "recentDocuments", cwd, documents: await collectRecentDocuments(ctx, cwd) });
630
+ await rememberPanelRecentScope(ctx, panel, cwd, sessionId, spaceId);
631
+ const recent = await collectRecentDocuments(ctx, cwd, sessionId, spaceId);
632
+ await panel.postMessage({ type: "recentDocuments", cwd, documents: recent.documents, fallbackCwd: recent.fallbackCwd });
535
633
  } catch (error) {
536
634
  ctx.logger.warn(`Could not collect recent documents: ${String(error)}`);
537
635
  await panel.postMessage({ type: "recentDocuments", cwd, documents: [] });
@@ -601,8 +699,11 @@ async function handleMessage(ctx, panel, raw) {
601
699
  return;
602
700
  }
603
701
  try {
604
- await writeFile(sourcePath, markdown, "utf8");
605
- await panel.postMessage({ type: "applied", path: sourcePath, title: documentTitle(markdown, sourcePath) });
702
+ const current = await readFile(sourcePath, "utf8");
703
+ const appliedMarkdown = preserveTextEnvelope(current, markdown);
704
+ await writeFile(sourcePath, appliedMarkdown, "utf8");
705
+ await rememberRecentPath(ctx, sourcePath, panel);
706
+ await panel.postMessage({ type: "applied", path: sourcePath, title: documentTitle(appliedMarkdown, sourcePath) });
606
707
  } catch (error) {
607
708
  await panel.postMessage({ type: "error", message: `Could not apply revision: ${error instanceof Error ? error.message : String(error)}` });
608
709
  }
@@ -706,6 +807,12 @@ function activate(ctx) {
706
807
  },
707
808
  "swatch-book": {
708
809
  svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 17a4 4 0 0 1-8 0V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2Z"/><path d="M16.7 13H19a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H7"/><path d="M 7 17h.01"/><path d="m11 8 2.3-2.3a2.4 2.4 0 0 1 3.404.004L18.6 7.6a2.4 2.4 0 0 1 .026 3.434L9.9 19.8"/></svg>'
810
+ },
811
+ // Static hourglass shown on the Preview button while bm.md rendering is
812
+ // in flight — a plain status hint, deliberately not animated (SMIL does
813
+ // not run on host-rendered SVGs and frame-swapping felt janky).
814
+ "hourglass": {
815
+ svg: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 22h14"/><path d="M5 2h14"/><path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/><path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>'
709
816
  }
710
817
  }));
711
818
  ctx.subscriptions.push(ctx.ui.onDidOpenPanel((panel) => {
@@ -733,15 +840,28 @@ function activate(ctx) {
733
840
  description: `Open, create, revise, or restyle a Markdown document in Markdown Editor.
734
841
  action:
735
842
  open \u2014 read an absolute local Markdown path and open it as an editable WeChat article preview
736
- 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)
737
- apply \u2014 replace a source document with reviewed Markdown (requires path and markdown); the open panel refreshes in place, no Diff window
843
+ 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 write an article, start writing, write a post, create, or draft a new document \u2014 even if they do not mention Markdown. If title/topic or destination is missing, guide the user to provide it; once known, create and open the document rather than returning prose only. If they only want to begin, create a minimal titled starter document. 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
844
+ apply \u2014 revise a source document (requires path). For a small, targeted change, pass edits instead of markdown: an array of {old_string, new_string} replacements matched against the file's current on-disk content, the same find-and-replace contract as a code editor's Edit tool \u2014 this avoids resending the whole document and keeps the on-screen highlight scoped to what actually changed. Reserve markdown (the full updated document) for a genuine full rewrite. Once this conversation has started editing a .md document through Markdown Editor, always use this apply/edits path for subsequent changes to that same file before considering the built-in Edit tool: it refreshes the panel and highlights the exact change. Fall back to the built-in Edit tool only after this apply actually fails. The open panel refreshes in place, no Diff window. Whenever you propose a rewrite and wait for approval before applying it, calling Session action=suggest with 1-3 one-tap confirmations is MANDATORY, not optional, and part of that same turn \u2014 sending the proposal text alone does not complete the confirmation step, so do not end the turn without also calling it
738
845
  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).`,
739
846
  inputSchema: {
740
847
  type: "object",
741
848
  properties: {
742
849
  action: { type: "string", enum: ["open", "create", "apply", "set_style"], description: "Operation to perform." },
743
850
  path: { type: "string", description: "Absolute path to the Markdown file. Required for open, create, and apply. For create, the file must not already exist." },
744
- markdown: { type: "string", description: "Full Markdown content, required for create and apply." },
851
+ markdown: { type: "string", description: "Full Markdown content. Required for create. For apply, use this only for a genuine full rewrite \u2014 prefer `edits` for a small, targeted change." },
852
+ edits: {
853
+ type: "array",
854
+ description: "For apply only: targeted local replacements instead of resending the whole document. Each old_string is matched against the file's current on-disk content (already reflecting earlier items in this same array) and must match exactly once unless replace_all is set. Prefer this over `markdown` for anything short of a full rewrite.",
855
+ items: {
856
+ type: "object",
857
+ properties: {
858
+ old_string: { type: "string", description: "Exact existing text to replace; must be unique in the file unless replace_all is true." },
859
+ new_string: { type: "string", description: "Replacement text." },
860
+ replace_all: { type: "boolean", description: "Replace every occurrence of old_string instead of requiring it to be unique." }
861
+ },
862
+ required: ["old_string", "new_string"]
863
+ }
864
+ },
745
865
  css: { type: "string", description: "Custom CSS to layer on top of the current base style, required for set_style." },
746
866
  label: { type: "string", description: "Short label describing the custom style, optional for set_style." },
747
867
  slot: { type: "number", enum: [1, 2, 3], description: "Required for AI-designed styles: user-selected reusable custom style slot to overwrite." }
@@ -784,10 +904,33 @@ action:
784
904
  return result(`Could not create ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`, true);
785
905
  }
786
906
  }
907
+ const rawEdits = Array.isArray(input.edits) ? input.edits : void 0;
908
+ if (rawEdits && rawEdits.length > 0) {
909
+ const edits = rawEdits.map((entry) => {
910
+ const item = entry && typeof entry === "object" ? entry : {};
911
+ return {
912
+ old_string: typeof item.old_string === "string" ? item.old_string : "",
913
+ new_string: typeof item.new_string === "string" ? item.new_string : "",
914
+ replace_all: Boolean(item.replace_all)
915
+ };
916
+ });
917
+ try {
918
+ const current = await readFile(sourcePath, "utf8");
919
+ const applied = applyEditSpecs(current, edits);
920
+ if (!applied.ok) return result(applied.error, true);
921
+ await writeFile(sourcePath, applied.content, "utf8");
922
+ await rememberRecentPath(ctx, sourcePath);
923
+ return result(`Applied ${edits.length} targeted edit${edits.length > 1 ? "s" : ""} to ${path.basename(sourcePath)}.`);
924
+ } catch (error) {
925
+ return result(`Could not apply edits: ${error instanceof Error ? error.message : String(error)}`, true);
926
+ }
927
+ }
787
928
  const markdown = String(input.markdown ?? "");
788
- if (!markdown) return result("`apply` requires non-empty `markdown`.", true);
929
+ if (!markdown) return result("`apply` requires either `edits` (targeted replacements) or non-empty `markdown` (full document).", true);
789
930
  try {
790
- await writeFile(sourcePath, markdown, "utf8");
931
+ const current = await readFile(sourcePath, "utf8");
932
+ await writeFile(sourcePath, preserveTextEnvelope(current, markdown), "utf8");
933
+ await rememberRecentPath(ctx, sourcePath);
791
934
  return result(`Applied reviewed Markdown to ${path.basename(sourcePath)}.`);
792
935
  } catch (error) {
793
936
  return result(`Could not apply revision: ${error instanceof Error ? error.message : String(error)}`, true);