v1.0.0 — fix inline-bundle build ($-replace dup + </script> escape), widget self-fetch, frontmatter strip, content-height; marketplace -> git.tlcailab.com
This commit is contained in:
parent
5f586a99cb
commit
5e1d66754a
8 changed files with 271 additions and 1561 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "markdown-review",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"description": "Antigravity-style inline commenting on markdown — highlight a span, leave a comment, send it to the agent. Batch 'Submit All', ephemeral 'Answer Now', and edit/review modes, rendered in a live MCP-Apps webview.",
|
||||
"author": { "name": "TLC AI Lab (JARVIS)" },
|
||||
"license": "MIT"
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ stays clean. (Memory-only and inline-marker backends are on the roadmap — see
|
|||
## Install
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.scannersend.org/tlc/markdown-review
|
||||
/plugin marketplace add https://git.tlcailab.com/tlc/markdown-review
|
||||
/plugin install markdown-review@tlc-plugins
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -49,8 +49,15 @@ async function buildApp() {
|
|||
logLevel: "info",
|
||||
});
|
||||
const js = result.outputFiles[0].text;
|
||||
// Escape any literal "</script>" inside the bundle, or the HTML parser closes
|
||||
// the inline <script> early and the whole bundle fails to parse. (markdown-it
|
||||
// and friends ship such strings.) "<\/script>" is identical JS, inert to HTML.
|
||||
const safeJs = js.replace(/<\/script>/gi, "<\\/script>");
|
||||
const shell = await readFile(path.join(root, "src/app/index.html"), "utf-8");
|
||||
const html = shell.replace("<!--APP_BUNDLE-->", `<script>${js}</script>`);
|
||||
// Use a replacer FUNCTION, not a string: a string replacement interprets "$&",
|
||||
// "$`", etc., and the minified bundle is full of "$" — which silently
|
||||
// duplicates/mangles the shell. A function replacement is taken verbatim.
|
||||
const html = shell.replace("<!--APP_BUNDLE-->", () => `<script>${safeJs}</script>`);
|
||||
await writeFile(path.join(dist, "mcp-app.html"), html, "utf-8");
|
||||
console.log("[build] dist/mcp-app.html");
|
||||
}
|
||||
|
|
|
|||
29
server/dist/index.js
vendored
29
server/dist/index.js
vendored
|
|
@ -56910,6 +56910,35 @@ function createServer() {
|
|||
};
|
||||
}
|
||||
);
|
||||
K3(
|
||||
server,
|
||||
"read_markdown",
|
||||
{
|
||||
title: "Read markdown (app fetch)",
|
||||
description: "Internal: the review viewer calls this to load a file's content directly when the host doesn't hand the open_markdown result to the webview. The model should not call this.",
|
||||
inputSchema: {
|
||||
path: external_exports.string(),
|
||||
mode: external_exports.enum(["edit", "review"]).default("edit")
|
||||
},
|
||||
outputSchema: {
|
||||
path: external_exports.string(),
|
||||
mode: external_exports.string(),
|
||||
markdown: external_exports.string(),
|
||||
existingComments: external_exports.array(external_exports.any())
|
||||
},
|
||||
_meta: { ui: { visibility: ["app"] } }
|
||||
},
|
||||
async ({ path: filePath, mode }) => {
|
||||
const abs = path2.resolve(process.cwd(), filePath);
|
||||
const markdown = await readFile2(abs, "utf-8");
|
||||
const sidecar = await loadSidecar(abs);
|
||||
const structured = { path: abs, mode, markdown, existingComments: sidecar.comments };
|
||||
return {
|
||||
content: [{ type: "text", text: `Loaded ${abs} (${sidecar.comments.length} comment(s)).` }],
|
||||
structuredContent: structured
|
||||
};
|
||||
}
|
||||
);
|
||||
K3(
|
||||
server,
|
||||
"submit_batch",
|
||||
|
|
|
|||
1590
server/dist/mcp-app.html
vendored
1590
server/dist/mcp-app.html
vendored
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,29 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Markdown Review</title>
|
||||
<script>
|
||||
/* Boot diagnostic — classic inline, runs before the app bundle.
|
||||
Surfaces a silent crash (or confirms the bundle never executed). */
|
||||
(function () {
|
||||
function show(t, c) {
|
||||
var d = document.getElementById("doc");
|
||||
if (d) d.innerHTML = '<pre style="color:' + (c || "#ff8888") + ';white-space:pre-wrap;font-size:11px;padding:10px;margin:0">' + String(t) + "</pre>";
|
||||
}
|
||||
window.__mdBootRan = true;
|
||||
window.addEventListener("error", function (e) {
|
||||
show("WIDGET ERROR: " + (e.message || e) + "\n" + ((e.error && e.error.stack) || ""));
|
||||
});
|
||||
window.addEventListener("unhandledrejection", function (e) {
|
||||
show("PROMISE REJECTED: " + ((e.reason && (e.reason.stack || e.reason.message)) || e.reason), "#ffcc66");
|
||||
});
|
||||
setTimeout(function () {
|
||||
var d = document.getElementById("doc");
|
||||
if (d && /Select text in the document/.test(d.textContent || "")) {
|
||||
show("Boot diagnostic ran (so inline scripts DO execute here), but the app bundle never started and threw no error. → bundle not executing / not injected.", "#88ccff");
|
||||
}
|
||||
}, 1500);
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0d12;
|
||||
|
|
@ -16,7 +39,7 @@
|
|||
--border: #232838;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
html, body { margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
|
|
@ -43,15 +66,16 @@
|
|||
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
|
||||
.seg button { border: 0; border-radius: 0; padding: 6px 10px; background: transparent; }
|
||||
.seg button.on { background: var(--accent-dim); }
|
||||
.wrap { display: flex; height: calc(100% - 49px); }
|
||||
.doc { flex: 1; overflow: auto; padding: 28px 36px; max-width: 820px; }
|
||||
.wrap { display: flex; align-items: flex-start; }
|
||||
.doc { flex: 1; padding: 28px 36px; max-width: 820px; }
|
||||
.doc h1,.doc h2,.doc h3 { line-height: 1.25; }
|
||||
.doc pre { background: #0f1219; padding: 12px 14px; border-radius: 8px; overflow: auto; }
|
||||
.doc code { background: #0f1219; padding: 1px 5px; border-radius: 4px; }
|
||||
.doc blockquote { border-left: 3px solid var(--accent-dim); margin: 0; padding-left: 14px; color: var(--muted); }
|
||||
mark.mr { background: var(--mark); border-radius: 2px; padding: 0 1px; }
|
||||
.rail { width: 270px; border-left: 1px solid var(--border); overflow: auto;
|
||||
padding: 14px; background: #0d1016; }
|
||||
padding: 14px; background: #0d1016;
|
||||
align-self: flex-start; position: sticky; top: 8px; max-height: 92vh; }
|
||||
.rail h4 { margin: 4px 0 10px; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: .6px; }
|
||||
.ccard { background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 9px 10px; margin-bottom: 9px; }
|
||||
|
|
|
|||
|
|
@ -50,8 +50,14 @@ const submitAllBtn = $<HTMLButtonElement>("submitAll");
|
|||
const app = new App({ name: "Markdown Review", version: "0.0.1" });
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────────
|
||||
/** Hide YAML frontmatter from the rendered view (it's metadata, not body). */
|
||||
function stripFrontmatter(src: string): string {
|
||||
return src.replace(/^?---\r?\n[\s\S]*?\r?\n---\r?\n?/, "");
|
||||
}
|
||||
function renderDoc(): void {
|
||||
docEl.innerHTML = md.render(state.markdown);
|
||||
// Render without frontmatter; anchoring still uses the full state.markdown,
|
||||
// so comment offsets stay correct against the real file.
|
||||
docEl.innerHTML = md.render(stripFrontmatter(state.markdown));
|
||||
pathEl.textContent = state.path || "no file";
|
||||
}
|
||||
|
||||
|
|
@ -181,21 +187,121 @@ async function sendToAgent(comments: Comment[], single: boolean): Promise<void>
|
|||
}
|
||||
|
||||
// ── host wiring ─────────────────────────────────────────────────────────────────
|
||||
function ingestResult(result: CallToolResult): void {
|
||||
const sc = result.structuredContent as
|
||||
| { path?: string; mode?: string; markdown?: string; existingComments?: Comment[] }
|
||||
| undefined;
|
||||
if (!sc?.markdown) return;
|
||||
state.path = sc.path ?? "";
|
||||
state.markdown = sc.markdown;
|
||||
state.mode = (sc.mode as "edit" | "review") ?? "edit";
|
||||
renderDoc();
|
||||
renderRail();
|
||||
type IngestShape = { path?: string; mode?: string; markdown?: string; existingComments?: Comment[] };
|
||||
|
||||
/** Pull the {path,mode,markdown,…} payload out of whatever shape the host delivers. */
|
||||
function coerceData(raw: unknown): IngestShape | undefined {
|
||||
if (!raw || typeof raw !== "object") return undefined;
|
||||
const r = raw as Record<string, unknown>;
|
||||
// 1) standard MCP: result.structuredContent
|
||||
const sc = r.structuredContent as IngestShape | undefined;
|
||||
if (sc && typeof sc.markdown === "string") return sc;
|
||||
// 2) some hosts hand the structured object straight through
|
||||
if (typeof r.markdown === "string") return r as IngestShape;
|
||||
// 3) last resort: a JSON payload embedded in a text content block
|
||||
const content = r.content as Array<{ type?: string; text?: string }> | undefined;
|
||||
if (Array.isArray(content)) {
|
||||
for (const blk of content) {
|
||||
if (blk?.type === "text" && typeof blk.text === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(blk.text) as IngestShape;
|
||||
if (typeof parsed.markdown === "string") return parsed;
|
||||
} catch {
|
||||
/* not JSON — ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
app.ontoolresult = ingestResult;
|
||||
app.onerror = console.error;
|
||||
app.connect();
|
||||
// Visible lifecycle log — failures are never a silent black box again.
|
||||
const diagLines: string[] = [];
|
||||
function diag(msg: string): void {
|
||||
console.info("[md-review]", msg);
|
||||
diagLines.push(msg);
|
||||
if (!state.markdown) {
|
||||
docEl.innerHTML =
|
||||
`<div class="empty"><strong>Markdown Review — loading…</strong>` +
|
||||
`<div style="margin-top:8px;font-family:monospace;font-size:11px;white-space:pre-wrap;opacity:.8">` +
|
||||
diagLines.map((l) => "• " + escapeHtml(l)).join("\n") +
|
||||
`</div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function ingestResult(result: unknown): boolean {
|
||||
const sc = coerceData(result);
|
||||
if (!sc?.markdown) return false;
|
||||
state.path = sc.path ?? state.path ?? "";
|
||||
state.markdown = sc.markdown;
|
||||
state.mode = (sc.mode as "edit" | "review") ?? state.mode ?? "edit";
|
||||
state.batch = Array.isArray(sc.existingComments) ? (sc.existingComments as Comment[]) : state.batch;
|
||||
renderDoc();
|
||||
renderRail();
|
||||
return true;
|
||||
}
|
||||
|
||||
let loaded = false;
|
||||
let connected = false;
|
||||
|
||||
async function fetchContent(): Promise<void> {
|
||||
if (loaded || !state.path) return;
|
||||
diag("fetching content for " + state.path);
|
||||
try {
|
||||
const r = await app.callServerTool({
|
||||
name: "read_markdown",
|
||||
arguments: { path: state.path, mode: state.mode },
|
||||
});
|
||||
if (ingestResult(r)) {
|
||||
loaded = true;
|
||||
diag("loaded ✓");
|
||||
} else {
|
||||
diag("read_markdown returned no markdown: " + JSON.stringify(r).slice(0, 200));
|
||||
}
|
||||
} catch (e) {
|
||||
diag("read_markdown call failed: " + String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// The path arrives via the tool INPUT; we then fetch the file ourselves.
|
||||
app.ontoolinput = (params: unknown) => {
|
||||
const p = params as { arguments?: Record<string, unknown> } | undefined;
|
||||
const args = (p?.arguments ?? (params as Record<string, unknown>) ?? {}) as Record<string, unknown>;
|
||||
diag("ontoolinput: " + JSON.stringify(args).slice(0, 160));
|
||||
if (typeof args.path === "string") {
|
||||
state.path = args.path;
|
||||
pathEl.textContent = args.path;
|
||||
}
|
||||
if (args.mode === "edit" || args.mode === "review") state.mode = args.mode;
|
||||
if (connected) void fetchContent();
|
||||
};
|
||||
|
||||
// If the host DOES deliver the result, use it directly (no fetch needed).
|
||||
app.ontoolresult = (result: CallToolResult) => {
|
||||
diag("ontoolresult received");
|
||||
if (ingestResult(result)) loaded = true;
|
||||
};
|
||||
|
||||
app.onerror = (e: unknown) => diag("app error: " + String(e));
|
||||
|
||||
diag("widget loaded; connecting…");
|
||||
app
|
||||
.connect()
|
||||
.then(() => {
|
||||
connected = true;
|
||||
diag("connected");
|
||||
if (state.path) void fetchContent();
|
||||
setTimeout(() => {
|
||||
if (!loaded) {
|
||||
diag(
|
||||
state.path
|
||||
? "no document yet (path known — fetch pending/failed above)"
|
||||
: "no path delivered by host (neither ontoolinput nor ontoolresult fired with one)",
|
||||
);
|
||||
}
|
||||
}, 2500);
|
||||
})
|
||||
.catch((e) => diag("connect FAILED: " + String(e)));
|
||||
|
||||
// ── utils ───────────────────────────────────────────────────────────────────────
|
||||
let seq = 0;
|
||||
|
|
|
|||
|
|
@ -99,6 +99,40 @@ export function createServer(): McpServer {
|
|||
},
|
||||
);
|
||||
|
||||
// ── read_markdown (app-only: webview fetches its own content) ───────────────
|
||||
registerAppTool(
|
||||
server,
|
||||
"read_markdown",
|
||||
{
|
||||
title: "Read markdown (app fetch)",
|
||||
description:
|
||||
"Internal: the review viewer calls this to load a file's content directly " +
|
||||
"when the host doesn't hand the open_markdown result to the webview. " +
|
||||
"The model should not call this.",
|
||||
inputSchema: {
|
||||
path: z.string(),
|
||||
mode: z.enum(["edit", "review"]).default("edit"),
|
||||
},
|
||||
outputSchema: {
|
||||
path: z.string(),
|
||||
mode: z.string(),
|
||||
markdown: z.string(),
|
||||
existingComments: z.array(z.any()),
|
||||
},
|
||||
_meta: { ui: { visibility: ["app"] } },
|
||||
},
|
||||
async ({ path: filePath, mode }): Promise<CallToolResult> => {
|
||||
const abs = path.resolve(process.cwd(), filePath);
|
||||
const markdown = await readFile(abs, "utf-8");
|
||||
const sidecar = await loadSidecar(abs);
|
||||
const structured = { path: abs, mode, markdown, existingComments: sidecar.comments };
|
||||
return {
|
||||
content: [{ type: "text", text: `Loaded ${abs} (${sidecar.comments.length} comment(s)).` }],
|
||||
structuredContent: structured,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── submit_batch (app-only) ─────────────────────────────────────────────────
|
||||
registerAppTool(
|
||||
server,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue