Skip to content

Commit a986d22

Browse files
cdeustclaude
andcommitted
feat(wiki redesign): Phase 8 — inline editor with live KaTeX preview
The wiki is now authoring-capable. Open any page → click "Edit" → source-on-left / preview-on-right CodeMirror 6 editor → save → file written atomically. Backend (handlers/wiki_api.py + servers): - save_wiki_page(wiki_root, rel_path, body) — 2 MB cap, delegates path validation to wiki_store.write_page (CodeQL-verified commonpath sanitizer) - POST /api/wiki/save wired into both http_viz_server and http_standalone; JSON body {rel_path, body}; CORS-friendly Frontend (ui/unified-viz.html + js/wiki.js + knowledge.css): - KaTeX loaded from jsDelivr (CSS + js + auto-render). Active in BOTH view mode and preview — scientific math renders live. Delimiters: $…$, $$…$$, \(…\), \[…\]. - "Edit" button appears on every wiki page header. - openEditor() lazy-loads CodeMirror 6 from esm.sh (markdown lang, one-dark theme, history, autocomplete, lineWrapping). ~300 KB cost paid only when the user opts in. - Split-pane editor: source (CM6) on the left, live preview with KaTeX on the right, synchronised via the EditorView update listener. Full source including frontmatter is editable. - Toolbar: title, Cancel (confirm-on-discard), Save. Save POSTs to /api/wiki/save then reloads the view on success. - _splitFrontmatter / _reconstructSource round-trip the YAML envelope so edits to metadata persist. Verified save round-trip against a tempdir: create/read/append-like semantics intact; traversal + oversize rejected cleanly. LaTeX aesthetic preserved — IBM Plex Mono for the source pane, EB Garamond for preview + toolbar title. Phase 9 (academic extensions — BibTeX, figure numbering, citations) and Phase 10 (Pandoc export) are next. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 46ce19a commit a986d22

6 files changed

Lines changed: 429 additions & 1 deletion

File tree

mcp_server/handlers/wiki_api.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,9 +318,40 @@ def execute_view(name: str | None, inline_query: str | None = None) -> dict:
318318
}
319319

320320

321+
def save_wiki_page(wiki_root: Path, rel_path: str, body: str) -> dict:
322+
"""Write ``body`` to ``<wiki_root>/<rel_path>`` atomically.
323+
324+
Used by the in-browser editor (Phase 8.4). Path validation is
325+
performed by infrastructure/wiki_store.write_page (commonpath
326+
sanitizer — CodeQL-verified Phase 6 refactor).
327+
328+
Returns {"ok": True, "rel_path": ..., "bytes": N} on success,
329+
or {"error": ...} on failure. Never raises.
330+
"""
331+
if not rel_path or not isinstance(rel_path, str):
332+
return {"error": "rel_path required"}
333+
if body is None:
334+
return {"error": "body required"}
335+
if len(body) > 2_000_000:
336+
return {"error": "body too large (> 2 MB)"}
337+
try:
338+
from mcp_server.infrastructure.wiki_store import write_page
339+
340+
result = write_page(wiki_root, rel_path, body, mode="replace")
341+
return {
342+
"ok": True,
343+
"rel_path": result.path,
344+
"bytes_written": result.bytes_written,
345+
"mode": result.mode,
346+
}
347+
except Exception as e:
348+
return {"error": str(e)}
349+
350+
321351
__all__ = [
322352
"list_wiki_pages",
323353
"read_wiki_page",
354+
"save_wiki_page",
324355
"page_meta",
325356
"list_concepts",
326357
"list_drafts",

mcp_server/server/http_standalone.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,18 @@ def do_OPTIONS(self):
238238
_touch()
239239
self.send_response(204)
240240
self.send_header("Access-Control-Allow-Origin", "*")
241-
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
241+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
242242
self.end_headers()
243243

244+
def do_POST(self):
245+
_touch()
246+
path_no_qs = self.path.split("?")[0]
247+
if path_no_qs == "/api/wiki/save":
248+
self._serve_wiki_save()
249+
else:
250+
self.send_response(404)
251+
self.end_headers()
252+
244253
def do_GET(self):
245254
_touch()
246255
path_no_qs = self.path.split("?")[0]
@@ -397,6 +406,38 @@ def _serve_wiki_db(self, op: str):
397406
self.end_headers()
398407
self.wfile.write(json.dumps({"error": str(e)}).encode())
399408

409+
def _serve_wiki_save(self):
410+
"""POST /api/wiki/save — body: JSON {rel_path, body}."""
411+
try:
412+
from mcp_server.handlers.wiki_api import save_wiki_page
413+
from mcp_server.infrastructure.config import METHODOLOGY_DIR
414+
415+
length = int(self.headers.get("Content-Length") or 0)
416+
if length <= 0 or length > 4_000_000:
417+
self.send_response(400)
418+
self.send_header("Content-Type", "application/json")
419+
self.end_headers()
420+
self.wfile.write(
421+
json.dumps({"error": "invalid content-length"}).encode()
422+
)
423+
return
424+
payload = json.loads(self.rfile.read(length))
425+
rel_path = payload.get("rel_path", "")
426+
body = payload.get("body", "")
427+
result = save_wiki_page(METHODOLOGY_DIR / "wiki", rel_path, body)
428+
out = json.dumps(result, default=str).encode()
429+
self.send_response(200)
430+
self.send_header("Content-Type", "application/json")
431+
self.send_header("Access-Control-Allow-Origin", "*")
432+
self.send_header("Cache-Control", "no-cache")
433+
self.end_headers()
434+
self.wfile.write(out)
435+
except Exception as e:
436+
self.send_response(500)
437+
self.send_header("Content-Type", "application/json")
438+
self.end_headers()
439+
self.wfile.write(json.dumps({"error": str(e)}).encode())
440+
400441
def _serve_graph(self):
401442
try:
402443
data = _get_graph_response(self.path)

mcp_server/server/http_viz_server.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,14 @@ class UnifiedHandler(BaseHTTPRequestHandler):
128128
def do_OPTIONS(self):
129129
send_cors_options(self)
130130

131+
def do_POST(self):
132+
path_no_qs = self.path.split("?")[0]
133+
if path_no_qs == "/api/wiki/save":
134+
self._serve_wiki_save()
135+
else:
136+
self.send_response(404)
137+
self.end_headers()
138+
131139
def do_GET(self):
132140
_reset_unified_idle_timer()
133141
path_no_qs = self.path.split("?")[0]
@@ -287,6 +295,26 @@ def _serve_wiki_view(self):
287295
except Exception as e:
288296
send_error_response(self, e)
289297

298+
def _serve_wiki_save(self):
299+
"""POST /api/wiki/save — body: JSON {rel_path, body}."""
300+
try:
301+
import json as _json
302+
303+
from mcp_server.handlers.wiki_api import save_wiki_page
304+
from mcp_server.infrastructure.config import METHODOLOGY_DIR
305+
306+
length = int(self.headers.get("Content-Length") or 0)
307+
if length <= 0 or length > 4_000_000:
308+
send_json_response(self, {"error": "invalid content-length"})
309+
return
310+
payload = _json.loads(self.rfile.read(length))
311+
rel_path = payload.get("rel_path", "")
312+
body = payload.get("body", "")
313+
result = save_wiki_page(METHODOLOGY_DIR / "wiki", rel_path, body)
314+
send_json_response(self, result)
315+
except Exception as e:
316+
send_error_response(self, e)
317+
290318
def _serve_discussion_detail(self, path_no_qs: str):
291319
try:
292320
session_id = path_no_qs.rsplit("/", 1)[-1]

ui/unified-viz.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
<link rel="stylesheet" href="/css/timeline.css">
1212
<link rel="stylesheet" href="/css/sankey.css">
1313
<link rel="stylesheet" href="/css/knowledge.css">
14+
<!-- KaTeX for scientific math rendering (load early; math appears in view mode too) -->
15+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" crossorigin="anonymous">
16+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js" crossorigin="anonymous"></script>
17+
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js" crossorigin="anonymous"></script>
1418
</head>
1519
<body>
1620

ui/unified/js/wiki.js

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,12 +540,36 @@
540540
}
541541
pageHeader.appendChild(metaBar);
542542

543+
// Edit button (toggles inline editor)
544+
var editBtn = el('button', 'wiki-edit-btn');
545+
editBtn.type = 'button';
546+
editBtn.textContent = 'Edit';
547+
editBtn.addEventListener('click', function() {
548+
openEditor(main, data, pmeta);
549+
});
550+
pageHeader.appendChild(editBtn);
551+
543552
article.appendChild(pageHeader);
544553

545554
// Body
546555
var bodyEl = el('div', 'wiki-body');
547556
bodyEl.innerHTML = renderMarkdown(body);
548557

558+
// KaTeX math — renders $…$ and $$…$$ spans to real math.
559+
if (window.renderMathInElement) {
560+
try {
561+
window.renderMathInElement(bodyEl, {
562+
delimiters: [
563+
{ left: '$$', right: '$$', display: true },
564+
{ left: '$', right: '$', display: false },
565+
{ left: '\\(', right: '\\)', display: false },
566+
{ left: '\\[', right: '\\]', display: true }
567+
],
568+
throwOnError: false
569+
});
570+
} catch (e) { /* KaTeX optional; swallow failures */ }
571+
}
572+
549573
// Wire internal wiki links
550574
bodyEl.querySelectorAll('.wiki-link').forEach(function(link) {
551575
link.addEventListener('click', function() {
@@ -953,6 +977,189 @@
953977
return e;
954978
}
955979

980+
// ── Inline editor (Phase 8.3) ──
981+
//
982+
// Lazy-loads CodeMirror 6 from esm.sh the first time the user clicks
983+
// Edit. Keeps the initial wiki page load light (~200KB CM6 bundle
984+
// isn't paid until needed). Split-pane: left = source, right = live
985+
// markdown preview via the existing renderMarkdown + KaTeX.
986+
987+
var _cmModulesPromise = null;
988+
function _loadCodeMirror() {
989+
if (_cmModulesPromise) return _cmModulesPromise;
990+
_cmModulesPromise = (async function() {
991+
// Core + markdown mode + theme — via esm.sh (zero build, cached)
992+
var urls = {
993+
view: 'https://esm.sh/@codemirror/view@6',
994+
state: 'https://esm.sh/@codemirror/state@6',
995+
commands: 'https://esm.sh/@codemirror/commands@6',
996+
lang: 'https://esm.sh/@codemirror/lang-markdown@6',
997+
oneDark: 'https://esm.sh/@codemirror/theme-one-dark@6',
998+
autoClose: 'https://esm.sh/@codemirror/autocomplete@6'
999+
};
1000+
var mods = {};
1001+
await Promise.all(Object.keys(urls).map(async function(k) {
1002+
mods[k] = await import(urls[k]);
1003+
}));
1004+
return mods;
1005+
})();
1006+
return _cmModulesPromise;
1007+
}
1008+
1009+
async function openEditor(main, data, pmeta) {
1010+
var original = main.innerHTML;
1011+
main.innerHTML = '<div class="wiki-loading"><div class="wiki-loading-spinner"></div>Loading editor\u2026</div>';
1012+
1013+
var mods;
1014+
try {
1015+
mods = await _loadCodeMirror();
1016+
} catch (err) {
1017+
console.warn('[cortex] CodeMirror load failed', err);
1018+
main.innerHTML = original;
1019+
alert('Editor failed to load. See console for details.');
1020+
return;
1021+
}
1022+
1023+
main.innerHTML = '';
1024+
var wrap = el('div', 'wiki-editor-wrap');
1025+
1026+
// Toolbar: title, save, cancel
1027+
var toolbar = el('div', 'wiki-editor-toolbar');
1028+
var title = el('h2', 'wiki-editor-title');
1029+
title.textContent = (data.meta && data.meta.title) || data.path;
1030+
var spacer = el('span', 'wiki-editor-spacer');
1031+
var cancelBtn = el('button', 'wiki-editor-btn wiki-editor-cancel');
1032+
cancelBtn.type = 'button';
1033+
cancelBtn.textContent = 'Cancel';
1034+
var saveBtn = el('button', 'wiki-editor-btn wiki-editor-save');
1035+
saveBtn.type = 'button';
1036+
saveBtn.textContent = 'Save';
1037+
toolbar.appendChild(title);
1038+
toolbar.appendChild(spacer);
1039+
toolbar.appendChild(cancelBtn);
1040+
toolbar.appendChild(saveBtn);
1041+
wrap.appendChild(toolbar);
1042+
1043+
// Split pane: left editor, right preview
1044+
var split = el('div', 'wiki-editor-split');
1045+
var leftCol = el('div', 'wiki-editor-pane wiki-editor-source');
1046+
var rightCol = el('div', 'wiki-editor-pane wiki-editor-preview');
1047+
var previewBody = el('div', 'wiki-body wiki-preview-body');
1048+
rightCol.appendChild(previewBody);
1049+
split.appendChild(leftCol);
1050+
split.appendChild(rightCol);
1051+
wrap.appendChild(split);
1052+
main.appendChild(wrap);
1053+
1054+
// Reconstruct full source (frontmatter + body) so the user can
1055+
// edit metadata inline. If server gave us both, merge them.
1056+
var fullSource = _reconstructSource(data.meta || {}, data.body || '');
1057+
1058+
// Preview renderer with KaTeX
1059+
function rerender(src) {
1060+
var parts = _splitFrontmatter(src);
1061+
previewBody.innerHTML = renderMarkdown(parts.body);
1062+
if (window.renderMathInElement) {
1063+
try {
1064+
window.renderMathInElement(previewBody, {
1065+
delimiters: [
1066+
{ left: '$$', right: '$$', display: true },
1067+
{ left: '$', right: '$', display: false },
1068+
{ left: '\\(', right: '\\)', display: false },
1069+
{ left: '\\[', right: '\\]', display: true }
1070+
],
1071+
throwOnError: false
1072+
});
1073+
} catch (e) { /* noop */ }
1074+
}
1075+
}
1076+
1077+
// Build CM6 state + view
1078+
var EditorState = mods.state.EditorState;
1079+
var EditorView = mods.view.EditorView;
1080+
var keymap = mods.view.keymap;
1081+
var basicSetup = mods.commands.history ? [mods.commands.history()] : [];
1082+
var markdownLang = mods.lang.markdown();
1083+
var oneDark = mods.oneDark.oneDark;
1084+
var updateListener = EditorView.updateListener.of(function(upd) {
1085+
if (upd.docChanged) rerender(upd.state.doc.toString());
1086+
});
1087+
var cm = new EditorView({
1088+
state: EditorState.create({
1089+
doc: fullSource,
1090+
extensions: [
1091+
markdownLang,
1092+
oneDark,
1093+
updateListener,
1094+
EditorView.lineWrapping
1095+
]
1096+
}),
1097+
parent: leftCol
1098+
});
1099+
rerender(fullSource);
1100+
1101+
cancelBtn.addEventListener('click', function() {
1102+
if (!confirm('Discard changes?')) return;
1103+
loadPage(data.path);
1104+
});
1105+
1106+
saveBtn.addEventListener('click', async function() {
1107+
saveBtn.disabled = true;
1108+
saveBtn.textContent = 'Saving\u2026';
1109+
var newSource = cm.state.doc.toString();
1110+
try {
1111+
var resp = await fetch('/api/wiki/save', {
1112+
method: 'POST',
1113+
headers: { 'Content-Type': 'application/json' },
1114+
body: JSON.stringify({ rel_path: data.path, body: newSource })
1115+
});
1116+
var result = await resp.json();
1117+
if (!resp.ok || result.error) {
1118+
throw new Error(result.error || 'save failed');
1119+
}
1120+
saveBtn.textContent = 'Saved';
1121+
setTimeout(function() { loadPage(data.path); }, 300);
1122+
} catch (err) {
1123+
saveBtn.disabled = false;
1124+
saveBtn.textContent = 'Save';
1125+
alert('Save failed: ' + err.message);
1126+
}
1127+
});
1128+
}
1129+
1130+
function _splitFrontmatter(src) {
1131+
// Returns {frontmatter: str|'', body: str}. Recognises the standard
1132+
// `---\n…\n---\n` envelope; preserves everything else as body.
1133+
if (!src.startsWith('---\n') && !src.startsWith('---\r\n')) {
1134+
return { frontmatter: '', body: src };
1135+
}
1136+
var rest = src.slice(4);
1137+
var endRe = /(^|\n)---\s*(\n|$)/;
1138+
var m = endRe.exec(rest);
1139+
if (!m) return { frontmatter: '', body: src };
1140+
var fm = rest.slice(0, m.index);
1141+
var body = rest.slice(m.index + m[0].length);
1142+
return { frontmatter: fm, body: body };
1143+
}
1144+
1145+
function _reconstructSource(meta, body) {
1146+
// Server gives us parsed frontmatter + body separately; rebuild the
1147+
// full source for editing. Users can edit frontmatter directly.
1148+
if (!meta || Object.keys(meta).length === 0) return body || '';
1149+
var lines = ['---'];
1150+
Object.keys(meta).forEach(function(k) {
1151+
var v = meta[k];
1152+
if (v === null || v === undefined || v === '') return;
1153+
if (Array.isArray(v)) {
1154+
lines.push(k + ': [' + v.map(function(x) { return String(x); }).join(', ') + ']');
1155+
} else {
1156+
lines.push(k + ': ' + String(v));
1157+
}
1158+
});
1159+
lines.push('---', '', body || '');
1160+
return lines.join('\n');
1161+
}
1162+
9561163
// ── Init ──
9571164
document.addEventListener('DOMContentLoaded', init);
9581165
})();

0 commit comments

Comments
 (0)