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",
|
"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.",
|
"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)" },
|
"author": { "name": "TLC AI Lab (JARVIS)" },
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ stays clean. (Memory-only and inline-marker backends are on the roadmap — see
|
||||||
## Install
|
## 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
|
/plugin install markdown-review@tlc-plugins
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,15 @@ async function buildApp() {
|
||||||
logLevel: "info",
|
logLevel: "info",
|
||||||
});
|
});
|
||||||
const js = result.outputFiles[0].text;
|
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 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");
|
await writeFile(path.join(dist, "mcp-app.html"), html, "utf-8");
|
||||||
console.log("[build] dist/mcp-app.html");
|
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(
|
K3(
|
||||||
server,
|
server,
|
||||||
"submit_batch",
|
"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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Markdown Review</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0d12;
|
--bg: #0b0d12;
|
||||||
|
|
@ -16,7 +39,7 @@
|
||||||
--border: #232838;
|
--border: #232838;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; height: 100%; }
|
html, body { margin: 0; }
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
|
@ -43,15 +66,16 @@
|
||||||
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
|
.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 { border: 0; border-radius: 0; padding: 6px 10px; background: transparent; }
|
||||||
.seg button.on { background: var(--accent-dim); }
|
.seg button.on { background: var(--accent-dim); }
|
||||||
.wrap { display: flex; height: calc(100% - 49px); }
|
.wrap { display: flex; align-items: flex-start; }
|
||||||
.doc { flex: 1; overflow: auto; padding: 28px 36px; max-width: 820px; }
|
.doc { flex: 1; padding: 28px 36px; max-width: 820px; }
|
||||||
.doc h1,.doc h2,.doc h3 { line-height: 1.25; }
|
.doc h1,.doc h2,.doc h3 { line-height: 1.25; }
|
||||||
.doc pre { background: #0f1219; padding: 12px 14px; border-radius: 8px; overflow: auto; }
|
.doc pre { background: #0f1219; padding: 12px 14px; border-radius: 8px; overflow: auto; }
|
||||||
.doc code { background: #0f1219; padding: 1px 5px; border-radius: 4px; }
|
.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); }
|
.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; }
|
mark.mr { background: var(--mark); border-radius: 2px; padding: 0 1px; }
|
||||||
.rail { width: 270px; border-left: 1px solid var(--border); overflow: auto;
|
.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; }
|
.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;
|
.ccard { background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||||
padding: 9px 10px; margin-bottom: 9px; }
|
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" });
|
const app = new App({ name: "Markdown Review", version: "0.0.1" });
|
||||||
|
|
||||||
// ── render ────────────────────────────────────────────────────────────────────
|
// ── 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 {
|
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";
|
pathEl.textContent = state.path || "no file";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,21 +187,121 @@ async function sendToAgent(comments: Comment[], single: boolean): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── host wiring ─────────────────────────────────────────────────────────────────
|
// ── host wiring ─────────────────────────────────────────────────────────────────
|
||||||
function ingestResult(result: CallToolResult): void {
|
type IngestShape = { path?: string; mode?: string; markdown?: string; existingComments?: Comment[] };
|
||||||
const sc = result.structuredContent as
|
|
||||||
| { path?: string; mode?: string; markdown?: string; existingComments?: Comment[] }
|
/** Pull the {path,mode,markdown,…} payload out of whatever shape the host delivers. */
|
||||||
| undefined;
|
function coerceData(raw: unknown): IngestShape | undefined {
|
||||||
if (!sc?.markdown) return;
|
if (!raw || typeof raw !== "object") return undefined;
|
||||||
state.path = sc.path ?? "";
|
const r = raw as Record<string, unknown>;
|
||||||
state.markdown = sc.markdown;
|
// 1) standard MCP: result.structuredContent
|
||||||
state.mode = (sc.mode as "edit" | "review") ?? "edit";
|
const sc = r.structuredContent as IngestShape | undefined;
|
||||||
renderDoc();
|
if (sc && typeof sc.markdown === "string") return sc;
|
||||||
renderRail();
|
// 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;
|
// Visible lifecycle log — failures are never a silent black box again.
|
||||||
app.onerror = console.error;
|
const diagLines: string[] = [];
|
||||||
app.connect();
|
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 ───────────────────────────────────────────────────────────────────────
|
// ── utils ───────────────────────────────────────────────────────────────────────
|
||||||
let seq = 0;
|
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) ─────────────────────────────────────────────────
|
// ── submit_batch (app-only) ─────────────────────────────────────────────────
|
||||||
registerAppTool(
|
registerAppTool(
|
||||||
server,
|
server,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue