feat: Add Markdown to ProseMirror converter

- New converter supports headings, lists, blockquotes, code blocks
- Documents now render with proper formatting in Outline
- Auto-update collection documentStructure on document creation
- Documents appear in sidebar automatically

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 21:15:10 +00:00
parent 114895ff56
commit 12d3b26454
10 changed files with 358 additions and 11 deletions

View File

@@ -68,7 +68,7 @@ async function main() {
JSON.stringify({
status: 'ok',
transport: 'streamable-http',
version: '1.3.13',
version: '1.3.14',
sessions: sessions.size,
stateful: STATEFUL,
tools: allTools.length
@@ -101,7 +101,7 @@ async function main() {
// Create MCP server
const server = createMcpServer(pgClient.getPool(), {
name: 'mcp-outline-http',
version: '1.3.13'
version: '1.3.14'
});
// Track session if stateful

View File

@@ -39,7 +39,7 @@ async function main() {
// Create MCP server with shared configuration
const server = createMcpServer(pgClient.getPool(), {
name: 'mcp-outline-postgresql',
version: '1.3.13'
version: '1.3.14'
});
// Connect stdio transport

View File

@@ -122,7 +122,7 @@ export function createMcpServer(
): Server {
const server = new Server({
name: config.name || 'mcp-outline-postgresql',
version: config.version || '1.3.13'
version: config.version || '1.3.14'
});
// Set capabilities (required for MCP v2.2+)

View File

@@ -6,6 +6,7 @@
import { Pool } from 'pg';
import { BaseTool, ToolResponse, DocumentArgs, GetDocumentArgs, CreateDocumentArgs, UpdateDocumentArgs, SearchDocumentsArgs, MoveDocumentArgs } from '../types/tools.js';
import { validatePagination, validateSortDirection, validateSortField, isValidUUID, sanitizeInput } from '../utils/security.js';
import { markdownToProseMirror } from '../utils/markdown-to-prosemirror.js';
/**
* 1. list_documents - Lista documentos publicados e drafts com filtros e paginação
@@ -240,6 +241,9 @@ const createDocument: BaseTool<CreateDocumentArgs> = {
await pgClient.query('BEGIN');
try {
// Convert Markdown to ProseMirror JSON
const proseMirrorContent = markdownToProseMirror(text);
const docQuery = `
INSERT INTO documents (
id, "urlId", title, text, "collectionId", "teamId", "parentDocumentId", "createdById",
@@ -250,7 +254,7 @@ const createDocument: BaseTool<CreateDocumentArgs> = {
gen_random_uuid(),
substring(md5(random()::text) from 1 for 10),
$1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW(), 1, false, false, false, ARRAY[$6]::uuid[],
1, '{"type": "doc", "content": [{"type": "paragraph"}]}'::jsonb
1, $10::jsonb
)
RETURNING id, "urlId", title, "collectionId", "publishedAt", "createdAt"
`;
@@ -264,7 +268,8 @@ const createDocument: BaseTool<CreateDocumentArgs> = {
userId,
userId,
args.template || false,
publishedAt
publishedAt,
JSON.stringify(proseMirrorContent)
];
const docResult = await pgClient.query(docQuery, docParams);
@@ -288,6 +293,24 @@ const createDocument: BaseTool<CreateDocumentArgs> = {
text
]);
// Update collection's documentStructure to include the new document
const urlSlug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const updateStructureQuery = `
UPDATE collections
SET "documentStructure" = COALESCE("documentStructure", '[]'::jsonb) || $1::jsonb,
"updatedAt" = NOW()
WHERE id = $2
`;
await pgClient.query(updateStructureQuery, [
JSON.stringify([{
id: newDoc.id,
url: `/doc/${urlSlug}-${newDoc.urlId}`,
title: title,
children: []
}]),
args.collection_id
]);
await pgClient.query('COMMIT');
return {

View File

@@ -0,0 +1,310 @@
/**
* Markdown to ProseMirror JSON converter
* Converts basic Markdown to Outline's ProseMirror schema
*/
interface ProseMirrorNode {
type: string;
attrs?: Record<string, unknown>;
content?: ProseMirrorNode[];
text?: string;
marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;
}
interface ProseMirrorDoc {
type: 'doc';
content: ProseMirrorNode[];
}
/**
* Convert Markdown text to ProseMirror JSON
*/
export function markdownToProseMirror(markdown: string): ProseMirrorDoc {
const lines = markdown.split('\n');
const content: ProseMirrorNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Empty line - skip
if (line.trim() === '') {
i++;
continue;
}
// Horizontal rule
if (/^(-{3,}|_{3,}|\*{3,})$/.test(line.trim())) {
content.push({ type: 'hr' });
i++;
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
content.push({
type: 'heading',
attrs: { level: headingMatch[1].length },
content: parseInlineContent(headingMatch[2])
});
i++;
continue;
}
// Code block
if (line.startsWith('```')) {
const language = line.slice(3).trim() || null;
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
content.push({
type: 'code_block',
attrs: { language },
content: [{ type: 'text', text: codeLines.join('\n') }]
});
i++; // skip closing ```
continue;
}
// Blockquote
if (line.startsWith('>')) {
const quoteLines: string[] = [];
while (i < lines.length && (lines[i].startsWith('>') || lines[i].trim() === '')) {
if (lines[i].startsWith('>')) {
quoteLines.push(lines[i].replace(/^>\s?/, ''));
}
i++;
if (lines[i]?.trim() === '' && !lines[i + 1]?.startsWith('>')) break;
}
content.push({
type: 'blockquote',
content: [{
type: 'paragraph',
content: parseInlineContent(quoteLines.join(' '))
}]
});
continue;
}
// Bullet list
if (/^[-*+]\s/.test(line)) {
const items: ProseMirrorNode[] = [];
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
const itemText = lines[i].replace(/^[-*+]\s+/, '');
items.push({
type: 'list_item',
content: [{ type: 'paragraph', content: parseInlineContent(itemText) }]
});
i++;
}
content.push({ type: 'bullet_list', content: items });
continue;
}
// Ordered list
if (/^\d+\.\s/.test(line)) {
const items: ProseMirrorNode[] = [];
while (i < lines.length && /^\d+\.\s/.test(lines[i])) {
const itemText = lines[i].replace(/^\d+\.\s+/, '');
items.push({
type: 'list_item',
content: [{ type: 'paragraph', content: parseInlineContent(itemText) }]
});
i++;
}
content.push({ type: 'ordered_list', content: items });
continue;
}
// Checkbox list
if (/^[-*]\s+\[[ x]\]/.test(line)) {
const items: ProseMirrorNode[] = [];
while (i < lines.length && /^[-*]\s+\[[ x]\]/.test(lines[i])) {
const checked = /\[x\]/i.test(lines[i]);
const itemText = lines[i].replace(/^[-*]\s+\[[ x]\]\s*/, '');
items.push({
type: 'checkbox_item',
attrs: { checked },
content: [{ type: 'paragraph', content: parseInlineContent(itemText) }]
});
i++;
}
content.push({ type: 'checkbox_list', content: items });
continue;
}
// Default: paragraph
content.push({
type: 'paragraph',
content: parseInlineContent(line)
});
i++;
}
// Ensure at least one empty paragraph if no content
if (content.length === 0) {
content.push({ type: 'paragraph' });
}
return { type: 'doc', content };
}
/**
* Parse inline content (bold, italic, links, code)
*/
function parseInlineContent(text: string): ProseMirrorNode[] {
if (!text || text.trim() === '') {
return [];
}
const nodes: ProseMirrorNode[] = [];
let remaining = text;
while (remaining.length > 0) {
// Inline code
const codeMatch = remaining.match(/^`([^`]+)`/);
if (codeMatch) {
nodes.push({
type: 'text',
text: codeMatch[1],
marks: [{ type: 'code_inline' }]
});
remaining = remaining.slice(codeMatch[0].length);
continue;
}
// Bold
const boldMatch = remaining.match(/^\*\*([^*]+)\*\*/) || remaining.match(/^__([^_]+)__/);
if (boldMatch) {
nodes.push({
type: 'text',
text: boldMatch[1],
marks: [{ type: 'bold' }]
});
remaining = remaining.slice(boldMatch[0].length);
continue;
}
// Italic
const italicMatch = remaining.match(/^\*([^*]+)\*/) || remaining.match(/^_([^_]+)_/);
if (italicMatch) {
nodes.push({
type: 'text',
text: italicMatch[1],
marks: [{ type: 'italic' }]
});
remaining = remaining.slice(italicMatch[0].length);
continue;
}
// Link
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
if (linkMatch) {
nodes.push({
type: 'text',
text: linkMatch[1],
marks: [{ type: 'link', attrs: { href: linkMatch[2] } }]
});
remaining = remaining.slice(linkMatch[0].length);
continue;
}
// Plain text - consume until next special char or end
const plainMatch = remaining.match(/^[^`*_\[]+/);
if (plainMatch) {
nodes.push({ type: 'text', text: plainMatch[0] });
remaining = remaining.slice(plainMatch[0].length);
continue;
}
// Fallback: consume single character
nodes.push({ type: 'text', text: remaining[0] });
remaining = remaining.slice(1);
}
return nodes;
}
/**
* Convert ProseMirror JSON back to Markdown
*/
export function proseMirrorToMarkdown(doc: ProseMirrorDoc): string {
return doc.content.map(node => nodeToMarkdown(node)).join('\n\n');
}
function nodeToMarkdown(node: ProseMirrorNode, indent = ''): string {
switch (node.type) {
case 'heading':
const level = (node.attrs?.level as number) || 1;
return '#'.repeat(level) + ' ' + contentToMarkdown(node.content);
case 'paragraph':
return contentToMarkdown(node.content);
case 'bullet_list':
return (node.content || [])
.map(item => '- ' + nodeToMarkdown(item.content?.[0] || { type: 'paragraph' }))
.join('\n');
case 'ordered_list':
return (node.content || [])
.map((item, i) => `${i + 1}. ` + nodeToMarkdown(item.content?.[0] || { type: 'paragraph' }))
.join('\n');
case 'checkbox_list':
return (node.content || [])
.map(item => {
const checked = item.attrs?.checked ? 'x' : ' ';
return `- [${checked}] ` + nodeToMarkdown(item.content?.[0] || { type: 'paragraph' });
})
.join('\n');
case 'blockquote':
return (node.content || [])
.map(child => '> ' + nodeToMarkdown(child))
.join('\n');
case 'code_block':
const lang = node.attrs?.language || '';
return '```' + lang + '\n' + contentToMarkdown(node.content) + '\n```';
case 'hr':
return '---';
default:
return contentToMarkdown(node.content);
}
}
function contentToMarkdown(content?: ProseMirrorNode[]): string {
if (!content) return '';
return content.map(node => {
if (node.type === 'text') {
let text = node.text || '';
if (node.marks) {
for (const mark of node.marks) {
switch (mark.type) {
case 'bold':
text = `**${text}**`;
break;
case 'italic':
text = `*${text}*`;
break;
case 'code_inline':
text = `\`${text}\``;
break;
case 'link':
text = `[${text}](${mark.attrs?.href})`;
break;
}
}
}
return text;
}
return nodeToMarkdown(node);
}).join('');
}