feat: implement initial Docmost Model Context Protocol (MCP) server for documentation management.

This commit is contained in:
Moritz Krause
2026-01-31 23:56:58 +01:00
commit 0900260765
10 changed files with 2324 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
build/
.env
*.log
.DS_Store
mcp_config.json

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Moritz Krause
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# Docmost MCP Server
A Model Context Protocol (MCP) server for [Docmost](https://docmost.com/), enabling AI agents to search, create, modify, and organize documentation pages and spaces.
## Features
### Core Management
- **`create_page`**: Smart creation tool. Creates content (via import) AND handles hierarchy (nesting under a parent) in one go.
- **`update_page`**: ⚠️ **IMPORTANT**: This tool **recreates the page** with a **new ID** and **deletes the old page**. While it preserves child pages and hierarchy, **all references to the old page ID will break**, including:
- Comments and discussions
- Page history/changelog
This is a workaround limitation due to Docmost's API not providing a native update endpoint. Use with caution!
- **`delete_page` / `delete_pages`**: Delete single or multiple pages at once.
- **`move_page`**: Organize pages hierarchically by moving them to a new parent or root.
### Exploration & Retrieval
- **`search`**: Full-text search across spaces with optional space filtering (`query`, `spaceId`).
- **`get_workspace`**: Get information about the current Docmost workspace.
- **`list_spaces`**: View all spaces within the current workspace.
- **`list_groups`**: View all groups within the current workspace.
- **`list_pages`**: List pages within a space (ordered by `updatedAt` descending).
- **`get_page`**: Retrieve full content and metadata of a specific page.
### Technical Details
- **Automatic Markdown Conversion**: Page content is automatically converted from Docmost's internal ProseMirror/TipTap JSON format to clean Markdown for easy agent consumption. Supports all Docmost extensions including callouts, task lists, math blocks, embeds, and more.
- **Smart Import API**: Uses Docmost's import API to ensure clean Markdown-to-ProseMirror conversion when creating pages.
- **Child Preservation**: The `update_page` tool creates a new page ID but effectively simulates an in-place update by reparenting existing child pages to the new version.
- **Pagination Support**: Automatically handles pagination for large datasets (spaces, pages, groups).
- **Filtered Responses**: API responses are filtered to include only relevant information, optimizing data transfer for agents.
## Installation
```bash
npm install
npm run build
```
## Configuration
This server requires the following environment variables to be set:
- `DOCMOST_API_URL`: The full URL to your Docmost API (e.g., `https://docs.example.com/api`).
- `DOCMOST_EMAIL`: The email address for authentication.
- `DOCMOST_PASSWORD`: The password for authentication.
## usage with Claude Desktop / generic MCP Client
Add the following to your MCP configuration (e.g. `claude_desktop_config.json`):
```json
{
"mcpServers": {
"docmost": {
"command": "node",
"args": ["/path/to/docmost-mcp/build/index.js"],
"env": {
"DOCMOST_API_URL": "https://your-docmost-instance.com/api",
"DOCMOST_EMAIL": "your-email@example.com",
"DOCMOST_PASSWORD": "your-password"
}
}
}
}
```
## Development
```bash
# Watch mode
npm run watch
# Build
npm run build
```
## License
MIT

13
mcp_config_example.json Normal file
View File

@@ -0,0 +1,13 @@
{
"mcpServers": {
"docmost-local": {
"command": "node",
"args": ["./build/index.js"],
"env": {
"DOCMOST_API_URL": "http://localhost:3000/api",
"DOCMOST_EMAIL": "test@docmost.com",
"DOCMOST_PASSWORD": "test"
}
}
}
}

1313
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "docmost-mcp",
"version": "1.0.0",
"description": "A Model Context Protocol (MCP) server for Docmost, allowing AI agents to manage documentation spaces and pages.",
"main": "build/index.js",
"bin": {
"docmost-mcp": "build/index.js"
},
"scripts": {
"build": "tsc",
"start": "node build/index.js",
"watch": "tsc --watch",
"inspector": "npx @modelcontextprotocol/inspector --config mcp_config.json"
},
"keywords": [
"mcp",
"docmost",
"documentation",
"ai",
"agent"
],
"author": "Moritz Krause",
"license": "MIT",
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.3",
"axios": "^1.6.0",
"form-data": "^4.0.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/form-data": "^2.5.0",
"@types/node": "^20.0.0",
"typescript": "^5.0.0"
}
}

70
src/filters.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Filter functions to extract only relevant information from API responses
* for better agent consumption
*/
export function filterWorkspace(data: any) {
return {
id: data.id,
name: data.name,
description: data.description,
defaultSpaceId: data.defaultSpaceId,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
deletedAt: data.deletedAt,
};
}
export function filterSpace(space: any) {
return {
id: space.id,
name: space.name,
description: space.description,
slug: space.slug,
visibility: space.visibility,
createdAt: space.createdAt,
updatedAt: space.updatedAt,
deletedAt: space.deletedAt,
};
}
export function filterGroup(group: any) {
return {
id: group.id,
name: group.name,
description: group.description,
workspaceId: group.workspaceId,
createdAt: group.createdAt,
updatedAt: group.updatedAt,
deletedAt: group.deletedAt,
};
}
export function filterPage(page: any, content?: string) {
return {
id: page.id,
title: page.title,
parentPageId: page.parentPageId,
spaceId: page.spaceId,
isLocked: page.isLocked,
createdAt: page.createdAt,
updatedAt: page.updatedAt,
deletedAt: page.deletedAt,
// Include converted markdown content if provided
...(content && { content }),
};
}
export function filterSearchResult(result: any) {
return {
id: result.id,
title: result.title,
parentPageId: result.parentPageId,
createdAt: result.createdAt,
updatedAt: result.updatedAt,
rank: result.rank,
highlight: result.highlight,
spaceId: result.space?.id,
spaceName: result.space?.name,
};
}

563
src/index.ts Normal file
View File

@@ -0,0 +1,563 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import FormData from "form-data";
import axios, { AxiosInstance } from "axios";
import { z } from "zod";
import {
filterWorkspace,
filterSpace,
filterGroup,
filterPage,
filterSearchResult,
} from "./filters.js";
import { convertProseMirrorToMarkdown } from "./markdown-converter.js";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { dirname, join } from "path";
// Read version from package.json
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const packageJson = JSON.parse(
readFileSync(join(__dirname, "../package.json"), "utf-8"),
);
const VERSION = packageJson.version;
const API_URL = process.env.DOCMOST_API_URL;
const EMAIL = process.env.DOCMOST_EMAIL;
const PASSWORD = process.env.DOCMOST_PASSWORD;
if (!API_URL || !EMAIL || !PASSWORD) {
console.error(
"Error: DOCMOST_API_URL, DOCMOST_EMAIL, and DOCMOST_PASSWORD environment variables are required.",
);
process.exit(1);
}
class DocmostClient {
// ... [Client Implementation stays exactly the same] ...
private client: AxiosInstance;
private token: string | null = null;
constructor(baseURL: string) {
this.client = axios.create({
baseURL,
headers: {
"Content-Type": "application/json",
},
});
}
async login() {
try {
const response = await this.client.post("/auth/login", {
email: EMAIL,
password: PASSWORD,
});
// Extract token from Set-Cookie header
const cookies = response.headers["set-cookie"];
if (!cookies) {
throw new Error("No Set-Cookie header found in login response");
}
const authCookie = cookies.find((c: string) =>
c.startsWith("authToken="),
);
if (!authCookie) {
throw new Error("No authToken cookie found in login response");
}
const token = authCookie.split(";")[0].split("=")[1];
this.token = token;
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`;
// console.error("Successfully logged in to Docmost API");
} catch (error: any) {
console.error("Login failed:", error.response?.data || error.message);
throw error;
}
}
async ensureAuthenticated() {
if (!this.token) {
await this.login();
}
}
/**
* Generic pagination handler for Docmost API endpoints
* @param endpoint - The API endpoint path (e.g., "/spaces", "/pages/recent")
* @param basePayload - Base payload object to send with each request
* @param limit - Items per page (min: 1, max: 100, default: 100)
* @returns All items collected from all pages
*/
async paginateAll<T = any>(
endpoint: string,
basePayload: Record<string, any> = {},
limit: number = 100,
): Promise<T[]> {
await this.ensureAuthenticated();
// Clamp limit between 1 and 100
const clampedLimit = Math.max(1, Math.min(100, limit));
let page = 1;
let allItems: T[] = [];
let hasNextPage = true;
while (hasNextPage) {
const response = await this.client.post(endpoint, {
...basePayload,
limit: clampedLimit,
page,
});
const data = response.data;
// Handle both direct data.items and data.data.items structures
const items = data.data?.items || data.items || [];
const meta = data.data?.meta || data.meta;
allItems = allItems.concat(items);
hasNextPage = meta?.hasNextPage || false;
page++;
}
return allItems;
}
async getWorkspace() {
await this.ensureAuthenticated();
const response = await this.client.post("/workspace/info", {});
return {
data: filterWorkspace(response.data.data),
success: response.data.success,
};
}
async getSpaces() {
const spaces = await this.paginateAll("/spaces", {});
return spaces.map((space) => filterSpace(space));
}
async getGroups() {
const groups = await this.paginateAll("/groups", {});
return groups.map((group) => filterGroup(group));
}
async listPages(spaceId?: string) {
const payload = spaceId ? { spaceId } : {};
const pages = await this.paginateAll("/pages/recent", payload);
return pages.map((page) => filterPage(page));
}
async getPage(pageId: string) {
await this.ensureAuthenticated();
const response = await this.client.post("/pages/info", { pageId });
return {
data: filterPage(
response.data.data,
response.data.data.content
? convertProseMirrorToMarkdown(response.data.data.content)
: undefined,
),
success: response.data.success,
};
}
/**
* Create a new page with title and content.
*
* Note: As long as Docmost doesn't provide a /pages/create endpoint that allows
* setting content directly, we must use the /pages/import workaround to create
* pages with initial content. This method:
* 1. Creates the page via /pages/import (which supports content)
* 2. Moves it to the correct parent if specified
*/
async createPage(
title: string,
content: string,
spaceId: string,
parentPageId?: string,
) {
await this.ensureAuthenticated();
if (parentPageId) {
try {
await this.getPage(parentPageId);
} catch (e) {
throw new Error(`Parent page with ID ${parentPageId} not found.`);
}
}
// 1. Create content via Import
const importRes = await this.importPage(title, content, spaceId);
const newPageId = importRes.data.id;
// 2. Move to parent if needed
if (parentPageId) {
await this.movePage(newPageId, parentPageId);
}
// Return the final page object
return this.getPage(newPageId);
}
/**
* Update a page's content and optionally its title.
*
* Note: As long as Docmost doesn't provide a /pages/update endpoint, we must use
* a "swap-and-replace" workaround to update page content. This method:
* 1. Fetches the old page details (space, parent, title)
* 2. Identifies all child pages
* 3. Creates a NEW page with the updated content via /pages/import
* 4. Moves the new page to the old page's position (same parent)
* 5. Re-parents all children to the new page
* 6. Deletes the old page
*
* ⚠️ IMPORTANT LIMITATION: The page will get a NEW ID! Any external references
* to the old page ID will break. The new ID is returned in the response.
*/
async updatePage(pageId: string, content: string, title?: string) {
await this.ensureAuthenticated();
// 1. Get old page details to know space and parent
const oldPageRes = await this.getPage(pageId);
const oldPage = oldPageRes.data;
const spaceId = oldPage.spaceId;
const parentPageId = oldPage.parentPageId;
const effectiveTitle = title || oldPage.title || "Untitled";
// 2. Identify Children
const allPages = await this.listPages(spaceId);
const children = allPages.filter((p: any) => p.parentPageId === pageId);
// 3. Create NEW page
const importRes = await this.importPage(effectiveTitle, content, spaceId);
const newPageId = importRes.data.id;
// 4. Move NEW page to OLD page's position (same parent)
if (parentPageId) {
await this.movePage(newPageId, parentPageId);
}
// 5. Reparent Children (Rescue them!)
for (const child of children) {
await this.movePage(child.id, newPageId);
}
// 6. Delete OLD page
await this.deletePage(pageId);
return {
success: true,
newMessage:
"Page updated safely (via swap-and-replace). Children preserved.",
newPageId: newPageId,
preservedChildrenCount: children.length,
};
}
async importPage(title: string, content: string, spaceId: string) {
await this.ensureAuthenticated();
const form = new FormData();
form.append("spaceId", spaceId);
// We create a virtual file
const fileContent = Buffer.from(content, "utf-8");
form.append("file", fileContent, {
filename: `${title || "import"}.md`,
contentType: "text/markdown",
});
const headers = {
...form.getHeaders(),
Authorization: `Bearer ${this.token}`,
};
return axios
.post(`${API_URL}/pages/import`, form, { headers })
.then((res) => res.data);
}
async search(query: string, spaceId?: string) {
await this.ensureAuthenticated();
const response = await this.client.post("/search", {
query,
spaceId,
});
// Filter search results (data is directly an array)
const items = response.data?.data || [];
const filteredItems = items.map((item: any) => filterSearchResult(item));
return {
items: filteredItems,
success: response.data?.success || false,
};
}
async movePage(
pageId: string,
parentPageId: string | null,
position?: string,
) {
await this.ensureAuthenticated();
// Docmost requires position >= 5 chars.
const validPosition = position || "a00000";
return this.client
.post("/pages/move", {
pageId,
parentPageId,
position: validPosition,
})
.then((res) => res.data);
}
async deletePage(pageId: string) {
await this.ensureAuthenticated();
return this.client
.post("/pages/delete", { pageId })
.then((res) => res.data);
}
async deletePages(pageIds: string[]) {
await this.ensureAuthenticated();
const promises = pageIds.map((id) =>
this.client
.post("/pages/delete", { pageId: id })
.then(() => ({ id, success: true }))
.catch((err: any) => ({ id, success: false, error: err.message })),
);
return Promise.all(promises);
}
}
const docmostClient = new DocmostClient(API_URL);
// --- Modern McpServer Implementation ---
const server = new McpServer({
name: "docmost-mcp",
version: VERSION,
});
// Helper to format JSON responses
const jsonContent = (data: any) => ({
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
});
// Tool: list_workspaces
server.registerTool(
"get_workspace",
{
description: "Get the current Docmost workspace",
},
async () => {
const workspace = await docmostClient.getWorkspace();
return jsonContent(workspace);
},
);
// Tool: list_spaces
server.registerTool(
"list_spaces",
{
description: "List all available spaces in Docmost",
},
async () => {
const spaces = await docmostClient.getSpaces();
return jsonContent(spaces);
},
);
// Tool: list_groups
server.registerTool(
"list_groups",
{
description: "List all available groups in Docmost",
},
async () => {
const groups = await docmostClient.getGroups();
return jsonContent(groups);
},
);
// Tool: list_pages
server.registerTool(
"list_pages",
{
description: "List pages in a space ordered by updatedAt (descending).",
inputSchema: {
spaceId: z.string().optional(),
},
},
async ({ spaceId }) => {
const result = await docmostClient.listPages(spaceId);
return jsonContent(result);
},
);
// Tool: get_page
server.registerTool(
"get_page",
{
description: "Get details and content of a specific page by ID",
inputSchema: {
pageId: z.string(),
},
},
async ({ pageId }) => {
const page = await docmostClient.getPage(pageId);
return jsonContent(page);
},
);
// Tool: create_page (Smart)
server.registerTool(
"create_page",
{
description:
"Create a new page with content (automatically moves it to the correct hierarchy).",
inputSchema: {
title: z.string().describe("Title of the page"),
content: z.string().describe("Markdown content"),
spaceId: z.string(),
parentPageId: z
.string()
.optional()
.describe("Optional parent page ID to nest under"),
},
},
async ({ title, content, spaceId, parentPageId }) => {
const result = await docmostClient.createPage(
title,
content,
spaceId,
parentPageId,
);
return jsonContent(result);
},
);
// Tool: update_page (Safe)
server.registerTool(
"update_page",
{
description:
"Update a page's content safely (preserves child pages by moving them). Note: Returns a NEW pageId.",
inputSchema: {
pageId: z.string().describe("ID of the page to update"),
content: z.string().describe("New Markdown content"),
title: z
.string()
.optional()
.describe("Optional new title (defaults to old title)"),
},
},
async ({ pageId, content, title }) => {
const result = await docmostClient.updatePage(pageId, content, title);
return jsonContent(result);
},
);
// Tool: move_page
server.registerTool(
"move_page",
{
description:
"Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'import_page'.",
inputSchema: {
pageId: z.string(),
parentPageId: z
.string()
.nullable()
.optional()
.describe(
"Target parent page ID. Pass 'null' or empty string to move to root.",
),
position: z
.string()
.optional()
.describe(
"Optional position string (5-12 chars). Defaults to 'a00000' (end) if omitted.",
),
},
},
async ({ pageId, parentPageId, position }) => {
// Ensure parentPageId is null if string "null" or empty is passed, or undefined
// Note: Zod handles type checking, but we double check for empty strings just in case
const finalParentId =
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
await docmostClient.movePage(pageId, finalParentId || null, position);
return {
content: [
{
type: "text",
text: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
},
],
};
},
);
// Tool: delete_page
server.registerTool(
"delete_page",
{
description: "Delete a single page by ID.",
inputSchema: {
pageId: z.string(),
},
},
async ({ pageId }) => {
await docmostClient.deletePage(pageId);
return {
content: [{ type: "text", text: `Successfully deleted page ${pageId}` }],
};
},
);
// Tool: delete_pages
server.registerTool(
"delete_pages",
{
description: "Delete multiple pages at once. Useful for cleanup.",
inputSchema: {
pageIds: z.array(z.string()),
},
},
async ({ pageIds }) => {
const results = await docmostClient.deletePages(pageIds);
return jsonContent(results);
},
);
// Tool: search
server.registerTool(
"search",
{
description: "Search for pages and content.",
inputSchema: {
query: z.string().describe("Search query"),
spaceId: z.string().optional().describe("Optional space ID to filter by"),
},
},
async ({ query, spaceId }) => {
const result = await docmostClient.search(query, spaceId);
return jsonContent(result);
},
);
async function run() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
run().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});

205
src/markdown-converter.ts Normal file
View File

@@ -0,0 +1,205 @@
/**
* Convert ProseMirror/TipTap JSON content to Markdown
* Supports all Docmost-specific node types and extensions
*/
export function convertProseMirrorToMarkdown(content: any): string {
if (!content || !content.content) return "";
const processNode = (node: any): string => {
const type = node.type;
const nodeContent = node.content || [];
switch (type) {
case "doc":
return nodeContent.map(processNode).join("\n\n");
case "paragraph":
const text = nodeContent.map(processNode).join("");
const align = node.attrs?.textAlign;
if (align && align !== "left") {
return `<div align="${align}">${text}</div>`;
}
return text || "";
case "heading":
const level = node.attrs?.level || 1;
const headingText = nodeContent.map(processNode).join("");
return "#".repeat(level) + " " + headingText;
case "text":
let textContent = node.text || "";
// Apply marks (bold, italic, code, etc.)
if (node.marks) {
for (const mark of node.marks) {
switch (mark.type) {
case "bold":
textContent = `**${textContent}**`;
break;
case "italic":
textContent = `*${textContent}*`;
break;
case "code":
textContent = `\`${textContent}\``;
break;
case "link":
textContent = `[${textContent}](${mark.attrs?.href || ""})`;
break;
case "strike":
textContent = `~~${textContent}~~`;
break;
case "underline":
textContent = `<u>${textContent}</u>`;
break;
case "subscript":
textContent = `<sub>${textContent}</sub>`;
break;
case "superscript":
textContent = `<sup>${textContent}</sup>`;
break;
case "highlight":
const color = mark.attrs?.color || "yellow";
textContent = `<mark style="background-color: ${color}">${textContent}</mark>`;
break;
case "textStyle":
if (mark.attrs?.color) {
textContent = `<span style="color: ${mark.attrs.color}">${textContent}</span>`;
}
break;
}
}
}
return textContent;
case "codeBlock":
const language = node.attrs?.language || "";
const code = nodeContent.map(processNode).join("");
return "```" + language + "\n" + code + "\n```";
case "bulletList":
return nodeContent
.map((item: any) => processListItem(item, "-"))
.join("\n");
case "orderedList":
return nodeContent
.map((item: any, index: number) =>
processListItem(item, `${index + 1}.`),
)
.join("\n");
case "taskList":
return nodeContent.map((item: any) => processTaskItem(item)).join("\n");
case "taskItem":
const checked = node.attrs?.checked || false;
const checkbox = checked ? "[x]" : "[ ]";
return `- ${checkbox} ${nodeContent.map(processNode).join("\n")}`;
case "listItem":
return nodeContent.map(processNode).join("\n");
case "blockquote":
return nodeContent.map((n: any) => "> " + processNode(n)).join("\n");
case "horizontalRule":
return "---";
case "hardBreak":
return "\n";
case "image":
const imgAlt = node.attrs?.alt || "";
const imgSrc = node.attrs?.src || "";
const imgCaption = node.attrs?.caption || "";
return `![${imgAlt}](${imgSrc})${imgCaption ? `\n*${imgCaption}*` : ""}`;
case "video":
const videoSrc = node.attrs?.src || "";
return `🎥 [Video](${videoSrc})`;
case "youtube":
const youtubeUrl = node.attrs?.src || "";
return `📺 [YouTube Video](${youtubeUrl})`;
case "table":
return nodeContent.map(processNode).join("\n");
case "tableRow":
return "| " + nodeContent.map(processNode).join(" | ") + " |";
case "tableCell":
case "tableHeader":
return nodeContent.map(processNode).join("");
case "callout":
const calloutType = node.attrs?.type || "info";
const calloutContent = nodeContent.map(processNode).join("\n");
return `> **${calloutType.toUpperCase()}**\n> ${calloutContent.replace(/\n/g, "\n> ")}`;
case "details":
return nodeContent.map(processNode).join("\n");
case "detailsSummary":
const summaryText = nodeContent.map(processNode).join("");
return `<details>\n<summary>${summaryText}</summary>\n`;
case "detailsContent":
const detailsText = nodeContent.map(processNode).join("\n");
return `${detailsText}\n</details>`;
case "mathInline":
const inlineMath = node.attrs?.latex || "";
return `$${inlineMath}$`;
case "mathBlock":
const blockMath = node.attrs?.latex || "";
return `$$\n${blockMath}\n$$`;
case "mention":
const mentionLabel = node.attrs?.label || node.attrs?.id || "";
return `@${mentionLabel}`;
case "attachment":
const attachmentName = node.attrs?.fileName || "attachment";
const attachmentUrl = node.attrs?.src || "";
return `📎 [${attachmentName}](${attachmentUrl})`;
case "drawio":
return `📊 [Draw.io Diagram]`;
case "excalidraw":
return `✏️ [Excalidraw Drawing]`;
case "embed":
const embedUrl = node.attrs?.src || "";
return `🔗 [Embedded Content](${embedUrl})`;
case "subpages":
return `📑 [Subpages List]`;
default:
// Fallback: process children
return nodeContent.map(processNode).join("");
}
};
const processListItem = (item: any, prefix: string): string => {
const itemContent = item.content || [];
const lines = itemContent.map(processNode);
return lines
.map((line: string, i: number) =>
i === 0 ? `${prefix} ${line}` : ` ${line}`,
)
.join("\n");
};
const processTaskItem = (item: any): string => {
const checked = item.attrs?.checked || false;
const checkbox = checked ? "[x]" : "[ ]";
const itemContent = item.content || [];
const text = itemContent.map(processNode).join("");
return `- ${checkbox} ${text}`;
};
return processNode(content).trim();
}

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"]
}