Skip to main content
Glama

Roam Research

by 2b3pro
markdown-utils.ts11.1 kB
import type { RoamCreateBlock, RoamCreatePage, RoamUpdateBlock, RoamDeleteBlock, RoamDeletePage, RoamMoveBlock } from '@roam-research/roam-api-sdk'; export type BatchAction = | RoamCreateBlock | RoamCreatePage | RoamUpdateBlock | RoamDeleteBlock | RoamDeletePage | RoamMoveBlock; interface MarkdownNode { content: string; level: number; heading_level?: number; // Optional heading level (1-3) for heading nodes children_view_type?: 'bullet' | 'document' | 'numbered'; // Optional view type for children children: MarkdownNode[]; } /** * Check if text has a traditional markdown table */ function hasMarkdownTable(text: string): boolean { return /^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+$/.test(text); } /** * Converts a markdown table to Roam format */ function convertTableToRoamFormat(text: string) { const lines = text.split('\n') .map(line => line.trim()) .filter(line => line.length > 0); const tableRegex = /^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+/m; if (!tableRegex.test(text)) { return text; } const rows = lines .filter((_, index) => index !== 1) .map(line => line.trim() .replace(/^\||\|$/g, '') .split('|') .map(cell => cell.trim()) ); let roamTable = '{{[[table]]}}\n'; // First row becomes column headers const headers = rows[0]; for (let i = 0; i < headers.length; i++) { roamTable += `${' '.repeat(i + 1)}- ${headers[i]}\n`; } // Remaining rows become nested under each column for (let rowIndex = 1; rowIndex < rows.length; rowIndex++) { const row = rows[rowIndex]; for (let colIndex = 0; colIndex < row.length; colIndex++) { roamTable += `${' '.repeat(colIndex + 1)}- ${row[colIndex]}\n`; } } return roamTable.trim(); } function convertAllTables(text: string) { return text.replaceAll( /(^\|([^|]+\|)+\s*$\n\|(\s*:?-+:?\s*\|)+\s*$\n(\|([^|]+\|)+\s*$\n*)+)/gm, (match) => { return '\n' + convertTableToRoamFormat(match) + '\n'; } ); } /** * Parse markdown heading syntax (e.g. "### Heading") and return the heading level (1-3) and content. * Heading level is determined by the number of # characters (e.g. # = h1, ## = h2, ### = h3). * Returns heading_level: 0 for non-heading content. */ function parseMarkdownHeadingLevel(text: string): { heading_level: number; content: string } { const match = text.match(/^(#{1,3})\s+(.+)$/); if (match) { return { heading_level: match[1].length, // Number of # characters determines heading level content: match[2].trim() }; } return { heading_level: 0, // Not a heading content: text.trim() }; } function convertToRoamMarkdown(text: string): string { // Handle double asterisks/underscores (bold) text = text.replace(/\*\*(.+?)\*\*/g, '**$1**'); // Preserve double asterisks // Handle single asterisks/underscores (italic) text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '__$1__'); // Single asterisk to double underscore text = text.replace(/(?<!_)_(?!_)(.+?)(?<!_)_(?!_)/g, '__$1__'); // Single underscore to double underscore // Handle highlights text = text.replace(/==(.+?)==/g, '^^$1^^'); // Convert tasks text = text.replace(/- \[ \]/g, '- {{[[TODO]]}}'); text = text.replace(/- \[x\]/g, '- {{[[DONE]]}}'); // Convert tables text = convertAllTables(text); return text; } function parseMarkdown(markdown: string): MarkdownNode[] { markdown = convertToRoamMarkdown(markdown); const originalLines = markdown.split('\n'); const processedLines: string[] = []; // Pre-process lines to handle mid-line code blocks without splice for (const line of originalLines) { const trimmedLine = line.trimEnd(); const codeStartIndex = trimmedLine.indexOf('```'); if (codeStartIndex > 0) { const indentationWhitespace = line.match(/^\s*/)?.[0] ?? ''; processedLines.push(indentationWhitespace + trimmedLine.substring(0, codeStartIndex)); processedLines.push(indentationWhitespace + trimmedLine.substring(codeStartIndex)); } else { processedLines.push(line); } } const rootNodes: MarkdownNode[] = []; const stack: MarkdownNode[] = []; let inCodeBlock = false; let codeBlockContent = ''; let codeBlockIndentation = 0; let codeBlockParentLevel = 0; for (let i = 0; i < processedLines.length; i++) { const line = processedLines[i]; const trimmedLine = line.trimEnd(); if (trimmedLine.match(/^(\s*)```/)) { if (!inCodeBlock) { inCodeBlock = true; codeBlockContent = trimmedLine.trimStart() + '\n'; codeBlockIndentation = line.match(/^\s*/)?.[0].length ?? 0; codeBlockParentLevel = stack.length; } else { inCodeBlock = false; codeBlockContent += trimmedLine.trimStart(); const linesInCodeBlock = codeBlockContent.split('\n'); let baseIndentation = ''; for (let j = 1; j < linesInCodeBlock.length - 1; j++) { const codeLine = linesInCodeBlock[j]; if (codeLine.trim().length > 0) { const indentMatch = codeLine.match(/^[\t ]*/); if (indentMatch) { baseIndentation = indentMatch[0]; break; } } } const processedCodeLines = linesInCodeBlock.map((codeLine, index) => { if (index === 0 || index === linesInCodeBlock.length - 1) return codeLine.trimStart(); if (codeLine.trim().length === 0) return ''; if (codeLine.startsWith(baseIndentation)) { return codeLine.slice(baseIndentation.length); } return codeLine.trimStart(); }); const level = Math.floor(codeBlockIndentation / 2); const node: MarkdownNode = { content: processedCodeLines.join('\n'), level, children: [] }; while (stack.length > codeBlockParentLevel) { stack.pop(); } if (level === 0) { rootNodes.push(node); stack[0] = node; } else { while (stack.length > level) { stack.pop(); } if (stack[level - 1]) { stack[level - 1].children.push(node); } else { rootNodes.push(node); } stack[level] = node; } codeBlockContent = ''; } continue; } if (inCodeBlock) { codeBlockContent += line + '\n'; continue; } if (trimmedLine === '') { continue; } const indentation = line.match(/^\s*/)?.[0].length ?? 0; let level = Math.floor(indentation / 2); let contentToParse: string; const bulletMatch = trimmedLine.match(/^(\s*)[-*+]\s+/); if (bulletMatch) { level = Math.floor(bulletMatch[1].length / 2); contentToParse = trimmedLine.substring(bulletMatch[0].length); } else { contentToParse = trimmedLine; } const { heading_level, content: finalContent } = parseMarkdownHeadingLevel(contentToParse); const node: MarkdownNode = { content: finalContent, level, ...(heading_level > 0 && { heading_level }), children: [] }; while (stack.length > level) { stack.pop(); } if (level === 0 || !stack[level - 1]) { rootNodes.push(node); stack[0] = node; } else { stack[level - 1].children.push(node); } stack[level] = node; } return rootNodes; } function parseTableRows(lines: string[]): MarkdownNode[] { const tableNodes: MarkdownNode[] = []; let currentLevel = -1; for (const line of lines) { const trimmedLine = line.trimEnd(); if (!trimmedLine) continue; // Calculate indentation level const indentation = line.match(/^\s*/)?.[0].length ?? 0; const level = Math.floor(indentation / 2); // Extract content after bullet point const content = trimmedLine.replace(/^\s*[-*+]\s*/, ''); // Create node for this cell const node: MarkdownNode = { content, level, children: [] }; // Track the first level we see to maintain relative nesting if (currentLevel === -1) { currentLevel = level; } // Add node to appropriate parent based on level if (level === currentLevel) { tableNodes.push(node); } else { // Find parent by walking back through nodes let parent = tableNodes[tableNodes.length - 1]; while (parent && parent.level < level - 1) { parent = parent.children[parent.children.length - 1]; } if (parent) { parent.children.push(node); } } } return tableNodes; } function generateBlockUid(): string { // Generate a random string of 9 characters (Roam's format) const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'; let uid = ''; for (let i = 0; i < 9; i++) { uid += chars.charAt(Math.floor(Math.random() * chars.length)); } return uid; } interface BlockInfo { uid: string; content: string; heading_level?: number; // Optional heading level (1-3) for heading nodes children_view_type?: 'bullet' | 'document' | 'numbered'; // Optional view type for children children: BlockInfo[]; } function convertNodesToBlocks(nodes: MarkdownNode[]): BlockInfo[] { return nodes.map(node => ({ uid: generateBlockUid(), content: node.content, ...(node.heading_level && { heading_level: node.heading_level }), // Preserve heading level if present children: convertNodesToBlocks(node.children) })); } function convertToRoamActions( nodes: MarkdownNode[], parentUid: string, order: 'first' | 'last' | number = 'last' ): BatchAction[] { // First convert nodes to blocks with UIDs const blocks = convertNodesToBlocks(nodes); const actions: BatchAction[] = []; // Helper function to recursively create actions function createBlockActions(blocks: BlockInfo[], parentUid: string, order: 'first' | 'last' | number): void { for (let i = 0; i < blocks.length; i++) { const block = blocks[i]; // Create the current block const action: RoamCreateBlock = { action: 'create-block', location: { 'parent-uid': parentUid, order: typeof order === 'number' ? order + i : i }, block: { uid: block.uid, string: block.content, ...(block.heading_level && { heading: block.heading_level }), ...(block.children_view_type && { 'children-view-type': block.children_view_type }) } }; actions.push(action); // Create child blocks if any if (block.children.length > 0) { createBlockActions(block.children, block.uid, 'last'); } } } // Create all block actions createBlockActions(blocks, parentUid, order); return actions; } // Export public functions and types export { parseMarkdown, convertToRoamActions, hasMarkdownTable, convertAllTables, convertToRoamMarkdown, parseMarkdownHeadingLevel };

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/2b3pro/roam-research-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server