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/README.md +10 -10
- package/dist/codemirror.js +34 -28
- package/dist/index.js +167 -24
- package/dist/panel.html +105 -29
- package/i18n/en-US.json +1 -1
- package/i18n/zh-CN.json +1 -1
- package/package.json +2 -2
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,
|
|
226
|
-
|
|
317
|
+
async function collectRecentDocuments(ctx, requestedCwd, sessionId, spaceId) {
|
|
318
|
+
const cwd = requestedCwd;
|
|
227
319
|
const state = await readLastPathState(ctx);
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
605
|
-
|
|
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
|
|
737
|
-
apply \u2014
|
|
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
|
|
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
|
|
929
|
+
if (!markdown) return result("`apply` requires either `edits` (targeted replacements) or non-empty `markdown` (full document).", true);
|
|
789
930
|
try {
|
|
790
|
-
await
|
|
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);
|