ai-knowledge-hub
Version:
MCP server that provides unified access to organizational knowledge across multiple platforms (local docs, Guru, Notion)
504 lines • 18.2 kB
JavaScript
/**
* Notion to Markdown conversion engine
*/
import { DEFAULT_CONVERSION_OPTIONS } from '../types/markdown.js';
/**
* Convert Notion blocks to Markdown
*/
export function notionBlocksToMarkdown(blocks, options = {}) {
const config = { ...DEFAULT_CONVERSION_OPTIONS, ...options };
const startTime = Date.now();
const warnings = [];
const errors = [];
const unsupportedBlocks = [];
const totalBlocks = blocks.length;
let convertedBlocks = 0;
let skippedBlocks = 0;
let errorBlocks = 0;
try {
const markdownLines = [];
// Add frontmatter if metadata is included
if (config.includeMetadata) {
markdownLines.push('---');
markdownLines.push(`# Generated: ${new Date().toISOString()}`);
markdownLines.push('---');
markdownLines.push('');
}
// Process blocks with proper list tracking
let numberedListCounter = 0;
let lastBlockType = '';
const processedBlockIds = new Set(); // Track blocks already processed by parents
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
// Skip blocks that have already been processed by their parent (e.g., table_row blocks processed by table)
if (typeof block.id === 'string' && processedBlockIds?.has(block.id)) {
continue;
}
try {
// Track numbered list continuity
if (block.type === 'numbered_list_item') {
if (lastBlockType !== 'numbered_list_item') {
numberedListCounter = 1; // Start new list
}
else {
numberedListCounter++; // Continue existing list
}
}
else {
numberedListCounter = 0; // Reset for non-list items
}
const markdown = convertNotionBlock(block, config, warnings, errors, unsupportedBlocks, numberedListCounter, 0, processedBlockIds);
if (markdown !== null) {
markdownLines.push(markdown);
// Add spacing based on block type and what follows
const nextBlock = blocks[i + 1];
if (nextBlock !== null) {
// Always add blank line after numbered list items
if (block.type === 'numbered_list_item') {
markdownLines.push('');
}
// Add blank line after headings
else if (block.type.startsWith('heading_')) {
markdownLines.push('');
}
// Add blank line before headings (except after another heading)
else if (nextBlock.type.startsWith('heading_') && !block.type.startsWith('heading_')) {
markdownLines.push('');
}
}
convertedBlocks++;
}
else {
skippedBlocks++;
}
lastBlockType = block.type;
}
catch (error) {
errors.push(`Error converting block ${block.type}: ${error instanceof Error ? error.message : String(error)}`);
errorBlocks++;
if (config.handleUnsupportedBlocks === 'convert') {
markdownLines.push(`<!-- Error converting ${block.type} block -->`);
}
else if (config.handleUnsupportedBlocks === 'error') {
throw new Error(`Failed to convert ${block.type}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
const markdown = markdownLines.join(config.lineBreaks === 'crlf' ? '\r\n' : '\n');
const processingTime = Date.now() - startTime;
const metadata = {
sourceFormat: 'notion',
targetFormat: 'markdown',
conversionDate: new Date(),
toolVersion: '1.0.0',
options: config,
processingTime,
nodeCount: totalBlocks,
};
const statistics = {
totalBlocks,
convertedBlocks,
skippedBlocks,
errorBlocks,
unsupportedBlocks: Array.from(new Set(unsupportedBlocks)),
warnings,
};
return {
content: markdown,
warnings,
errors,
metadata,
statistics,
};
}
catch (error) {
errors.push(`Conversion failed: ${error instanceof Error ? error.message : String(error)}`);
errorBlocks = totalBlocks;
return {
content: '',
warnings,
errors,
metadata: {
sourceFormat: 'notion',
targetFormat: 'markdown',
conversionDate: new Date(),
toolVersion: '1.0.0',
options: config,
processingTime: Date.now() - startTime,
nodeCount: totalBlocks,
},
statistics: {
totalBlocks,
convertedBlocks: 0,
skippedBlocks: 0,
errorBlocks,
unsupportedBlocks: Array.from(new Set(unsupportedBlocks)),
warnings,
},
};
}
}
/**
* Convert a single Notion block to markdown
*/
function convertNotionBlock(block, options, warnings, errors, unsupportedBlocks, numberedListCounter = 0, indentLevel = 0, processedBlockIds) {
let result = '';
switch (block.type) {
case 'paragraph':
result = convertParagraph(block, options);
break;
case 'heading_1':
case 'heading_2':
case 'heading_3':
result = convertHeading(block, options);
break;
case 'bulleted_list_item':
result = convertBulletedListItem(block, options, indentLevel);
break;
case 'numbered_list_item':
result = convertNumberedListItem(block, options, numberedListCounter, indentLevel);
break;
case 'to_do':
result = convertToDoItem(block, options, indentLevel);
break;
case 'code':
result = convertCodeBlock(block, options);
break;
case 'quote':
result = convertQuote(block, options);
break;
case 'divider':
result = convertDivider();
break;
case 'callout':
result = convertCallout(block, options, warnings);
break;
case 'toggle':
result = convertToggle(block, options, warnings);
break;
case 'image':
result = convertImage(block, options, warnings);
break;
case 'table':
result = convertTable(block, options, warnings, processedBlockIds);
break;
case 'table_row':
// Table rows should be handled by the table converter
// If we encounter one here, it means it's not properly nested under a table
// Let's silently skip it instead of generating a warning since this is a structural issue
return null;
case 'embed':
result = convertEmbed(block, options);
break;
case 'bookmark':
result = convertBookmark(block, options);
break;
default:
unsupportedBlocks.push(block.type);
warnings.push(`Unsupported block type: ${block.type}`);
if (options.handleUnsupportedBlocks === 'convert') {
result = `<!-- Unsupported block type: ${block.type} -->`;
}
else if (options.handleUnsupportedBlocks === 'error') {
throw new Error(`Unsupported block type: ${block.type}`);
}
else {
return null; // ignore
}
}
// Handle nested children if they exist
const children = block.children;
if (children && Array.isArray(children) && children.length > 0) {
const childrenMarkdown = [];
let childNumberedCounter = 0;
let lastChildType = '';
for (const child of children) {
// Track numbered list continuity for children
if (child.type === 'numbered_list_item') {
if (lastChildType !== 'numbered_list_item') {
childNumberedCounter = 1; // Start new list
}
else {
childNumberedCounter++; // Continue existing list
}
}
else {
childNumberedCounter = 0; // Reset for non-list items
}
const childMarkdown = convertNotionBlock(child, options, warnings, errors, unsupportedBlocks, childNumberedCounter, indentLevel + 1, processedBlockIds);
if (childMarkdown !== null) {
childrenMarkdown.push(childMarkdown);
}
lastChildType = child.type;
}
if (childrenMarkdown.length > 0) {
result += '\n' + childrenMarkdown.join('\n');
}
}
return result || null;
}
/**
* Convert paragraph block
*/
function convertParagraph(block, options) {
const text = convertRichTextToMarkdown(block.paragraph?.rich_text ?? [], options);
return text ?? '';
}
/**
* Convert heading block
*/
function convertHeading(block, options) {
const level = getHeadingLevel(block.type);
const headingContent = block[block.type];
const text = convertRichTextToMarkdown(headingContent?.rich_text ?? [], options);
const headingPrefix = '#'.repeat(level);
// Create anchor from heading text (lowercase, spaces to hyphens, remove special chars)
const anchor = text
.toLowerCase()
.replace(/[^\w\s-]/g, '') // Remove special characters except hyphens
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
// Add heading with anchor comment for positioning reference
return `${headingPrefix} ${text} {#${anchor}}`;
}
/**
* Convert bulleted list item
*/
function convertBulletedListItem(block, options, indentLevel) {
const text = convertRichTextToMarkdown(block.bulleted_list_item?.rich_text ?? [], options);
const marker = options.listMarker;
// Use 3 spaces for proper markdown indentation
const indent = indentLevel === 0 ? '' : ' '.repeat(indentLevel);
return `${indent}${marker} ${text}`;
}
/**
* Convert numbered list item
*/
function convertNumberedListItem(block, options, numberedListIndex, indentLevel) {
const text = convertRichTextToMarkdown(block.numbered_list_item?.rich_text ?? [], options);
// Use 3 spaces for proper markdown indentation
const indent = indentLevel === 0 ? '' : ' '.repeat(indentLevel);
return `${indent}${numberedListIndex}. ${text}`;
}
/**
* Convert to-do item
*/
function convertToDoItem(block, options, indentLevel) {
const text = convertRichTextToMarkdown(block.to_do?.rich_text ?? [], options);
const checked = block.to_do?.checked === true ? 'x' : ' ';
// Use 3 spaces for proper markdown indentation
const indent = indentLevel === 0 ? '' : ' '.repeat(indentLevel);
return `${indent}- [${checked}] ${text}`;
}
/**
* Convert code block
*/
function convertCodeBlock(block, options) {
const code = convertRichTextToMarkdown(block.code?.rich_text ?? [], options);
const language = block.code?.language ?? '';
if (options.codeBlockStyle === 'fenced') {
return `\`\`\`${language}\n${code}\n\`\`\``;
}
else {
// Indented style
const indentedCode = code
.split('\n')
.map(line => ' ' + line)
.join('\n');
return indentedCode;
}
}
/**
* Convert quote block
*/
function convertQuote(block, options) {
const text = convertRichTextToMarkdown(block.quote?.rich_text ?? [], options);
return `> ${text}`;
}
/**
* Convert divider
*/
function convertDivider() {
return '---';
}
/**
* Convert table block
*/
function convertTable(block, options, warnings, processedBlockIds) {
if (!block.children || block.children.length === 0) {
warnings.push('Table block has no rows');
return '';
}
const rows = [];
let isFirstRow = true;
// Process each table row
for (const row of block.children) {
if (row.type === 'table_row') {
// Mark this table_row as processed so it won't be processed again in the main loop
if (processedBlockIds !== null && processedBlockIds !== undefined && row.id !== null && row.id !== undefined) {
processedBlockIds.add(row.id);
}
const cells = row.table_row?.cells ?? [];
const cellTexts = [];
// Convert each cell
for (const cell of cells) {
const cellText = convertRichTextToMarkdown(cell, options);
// Escape pipes in cell content and clean up
const escapedText = cellText.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim();
cellTexts.push(escapedText || ' '); // Ensure no empty cells
}
// Create table row
const rowMarkdown = `| ${cellTexts.join(' | ')} |`;
rows.push(rowMarkdown);
// Add header separator after first row
if (isFirstRow) {
const separator = '|' + ' --- |'.repeat(cellTexts.length);
rows.push(separator);
isFirstRow = false;
}
}
}
if (rows.length === 0) {
warnings.push('No valid table rows found');
return '';
}
return rows.join('\n');
}
/**
* Convert callout block
*/
function convertCallout(block, options, warnings) {
if (!options.convertCallouts) {
warnings.push('Callout block skipped (convertCallouts disabled)');
return '';
}
const text = convertRichTextToMarkdown(block.callout?.rich_text ?? [], options);
const icon = block.callout?.icon?.emoji ?? '';
// Convert to blockquote with icon
return `> ${icon} ${text}`;
}
/**
* Convert toggle block
*/
function convertToggle(block, options, warnings) {
const text = convertRichTextToMarkdown(block.toggle?.rich_text ?? [], options);
if (!options.convertToggles) {
warnings.push('Toggle block converted to paragraph (convertToggles disabled)');
return text;
}
// Convert to HTML details element
return `<details>\n<summary>${text}</summary>\n\n<!-- Nested content would go here -->\n</details>`;
}
/**
* Convert image block
*/
function convertImage(block, options, warnings) {
const image = block.image;
if (!image) {
warnings.push('Image block missing image data');
return '';
}
const url = image.external?.url ?? image.file?.url ?? '';
const caption = convertRichTextToMarkdown(image.caption ?? [], options);
if (!url) {
warnings.push('Image block missing URL');
return '';
}
return ``;
}
/**
* Convert embed block
*/
function convertEmbed(block, options) {
const url = block.embed?.url ?? '';
const caption = convertRichTextToMarkdown(block.embed?.caption ?? [], options);
// Convert to link with caption
return caption ? `[${caption}](${url})` : url;
}
/**
* Convert bookmark block
*/
function convertBookmark(block, options) {
const url = block.bookmark?.url ?? '';
const caption = convertRichTextToMarkdown(block.bookmark?.caption ?? [], options);
return caption ? `[${caption}](${url})` : url;
}
/**
* Convert Notion rich text to markdown
*/
function convertRichTextToMarkdown(richText, options) {
if (richText === null || richText.length === 0) {
return '';
}
return richText
.map(text => convertSingleRichText(text, options))
.join('');
}
/**
* Convert a single rich text element to markdown
*/
function convertSingleRichText(text, options) {
let content = text.plain_text ?? '';
if (!content) {
return '';
}
const annotations = text.annotations;
// Apply formatting
if (annotations?.code) {
content = `\`${content}\``;
}
if (annotations?.bold) {
const marker = options.emphasisMarker === '*' ? '**' : '__';
content = `${marker}${content}${marker}`;
}
if (annotations?.italic) {
const marker = options.emphasisMarker;
content = `${marker}${content}${marker}`;
}
if (annotations?.strikethrough) {
content = `~~${content}~~`;
}
// Handle links
if ((text.href ?? text.text?.link?.url) !== null) {
const url = text.href ?? text.text?.link?.url;
if (url !== null) {
content = `[${content}](${url})`;
}
}
// Handle colors (if preserving)
if (options.preserveColors && annotations?.color && annotations.color !== 'default') {
content = `<span style="color: ${getColorValue(annotations.color)}">${content}</span>`;
}
return content;
}
/**
* Get heading level from block type
*/
function getHeadingLevel(blockType) {
switch (blockType) {
case 'heading_1': return 1;
case 'heading_2': return 2;
case 'heading_3': return 3;
default: return 1;
}
}
/**
* Convert Notion color to CSS color value
*/
function getColorValue(color) {
const colorMap = {
'gray': '#9B9A97',
'brown': '#64473A',
'orange': '#D9730D',
'yellow': '#DFAB01',
'green': '#0F7B6C',
'blue': '#0B6E99',
'purple': '#6940A5',
'pink': '#AD1A72',
'red': '#E03E3E',
'default': 'inherit',
};
return colorMap[color] ?? colorMap['default'];
}
//# sourceMappingURL=notion-to-markdown.js.map