/** * Docs module — collaborative documentation via Docmost. * * Wraps the Docmost instance as an external app embedded in the rSpace shell. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell, renderExternalAppShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import type { SyncServer } from '../../server/local-first/sync-server'; import { docsSchema, docsDocId } from './schemas'; import type { DocsDoc, LinkedDocument } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); const DOCMOST_URL = "https://docs.cosmolocal.world"; // ── Local-first helpers ── function ensureDocsDoc(space: string): DocsDoc { const docId = docsDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init docs registry', (d) => { const init = docsSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; }); _syncServer!.setDoc(docId, doc); } return doc; } // ── CRUD: Document Registry ── routes.get("/api/registry", (c) => { if (!_syncServer) return c.json({ documents: [] }); const space = c.req.param("space") || "demo"; const doc = ensureDocsDoc(space); return c.json({ documents: Object.values(doc.linkedDocuments || {}) }); }); routes.post("/api/registry", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const { url, title } = await c.req.json(); if (!url || !title) return c.json({ error: "url and title required" }, 400); const id = crypto.randomUUID(); const docId = docsDocId(space); ensureDocsDoc(space); _syncServer.changeDoc(docId, `register document ${id}`, (d) => { d.linkedDocuments[id] = { id, url, title, addedBy: claims.sub || null, addedAt: Date.now() }; }); const updated = _syncServer.getDoc(docId)!; return c.json(updated.linkedDocuments[id], 201); }); routes.delete("/api/registry/:docRefId", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const docRefId = c.req.param("docRefId"); const docId = docsDocId(space); const doc = ensureDocsDoc(space); if (!doc.linkedDocuments[docRefId]) return c.json({ error: "Not found" }, 404); _syncServer.changeDoc(docId, `unregister document ${docRefId}`, (d) => { delete d.linkedDocuments[docRefId]; }); return c.json({ ok: true }); }); routes.get("/api/health", (c) => { return c.json({ ok: true, module: "rdocs" }); }); routes.get("/", (c) => { const space = c.req.param("space") || "demo"; const dataSpace = c.get("effectiveSpace") || space; const view = c.req.query("view"); if (view === "demo") { return c.html(renderShell({ title: `${space} — Docs | rSpace`, moduleId: "rdocs", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: `
📝

rDocs

Collaborative documentation powered by Docmost. Create wikis, knowledge bases, and shared documents for your community.

Open Docmost
`, })); } // Default: show the external app directly return c.html(renderExternalAppShell({ title: `${space} — Docmost | rSpace`, moduleId: "rdocs", spaceSlug: space, modules: getModuleInfoList(), appUrl: DOCMOST_URL, appName: "Docmost", theme: "dark", })); }); function renderDocsLanding(): string { return `
📝

rDocs

Collaborative documentation powered by Docmost. Create wikis, knowledge bases, and shared documents for your community.

`; } // ── MI Integration ── export function getLinkedDocsForMI(space: string, limit = 5): { id: string; title: string; url: string; addedAt: number }[] { if (!_syncServer) return []; const docId = docsDocId(space); const doc = _syncServer.getDoc(docId); if (!doc) return []; return Object.values(doc.linkedDocuments) .sort((a, b) => b.addedAt - a.addedAt) .slice(0, limit) .map((d) => ({ id: d.id, title: d.title, url: d.url, addedAt: d.addedAt })); } export const docsModule: RSpaceModule = { id: "rdocs", name: "rDocs", icon: "📝", description: "Collaborative documentation and knowledge base", scoping: { defaultScope: 'global', userConfigurable: true }, docSchemas: [{ pattern: '{space}:docs:links', description: 'Linked Docmost documents per space', init: docsSchema.init }], routes, landingPage: renderDocsLanding, externalApp: { url: DOCMOST_URL, name: "Docmost" }, async onInit(ctx) { _syncServer = ctx.syncServer; }, feeds: [ { id: "documents", name: "Documents", kind: "data", description: "Collaborative documents and wiki pages" }, ], acceptsFeeds: ["data"], outputPaths: [ { path: "documents", name: "Documents", icon: "📝", description: "Collaborative documents" }, { path: "wikis", name: "Wikis", icon: "📖", description: "Knowledge base wikis" }, ], };