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:
Ravix DeWolf 2026-06-23 01:09:56 -05:00
parent 5f586a99cb
commit 5e1d66754a
8 changed files with 271 additions and 1561 deletions

View file

@ -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"

View file

@ -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
```

View file

@ -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
View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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; }

View file

@ -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;

View file

@ -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,