ai-knowledge-hub
Version:
MCP server that provides unified access to organizational knowledge across multiple platforms (local docs, Guru, Notion)
354 lines • 13.2 kB
JavaScript
/**
* Markdown to Notion conversion engine
*/
import { DEFAULT_CONVERSION_OPTIONS } from '../types/markdown.js';
// Notion API limits
const NOTION_CODE_BLOCK_MAX_CHARACTERS = 2000;
import { buildBulletedListItemBlock, buildCodeBlock, buildDividerBlock, buildFallbackBlock, buildHeadingBlock, buildImageBlock, buildNumberedListItemBlock, buildParagraphBlock, buildQuoteBlock, buildTableBlockFromNodes, buildToDoBlock, normalizeHeadingLevel, } from './notion-blocks.js';
/**
* Convert markdown AST to Notion blocks
*/
export function markdownASTToNotionBlocks(ast, options = {}) {
const config = { ...DEFAULT_CONVERSION_OPTIONS, ...options };
const startTime = Date.now();
const warnings = [];
const errors = [];
const unsupportedBlocks = [];
let totalBlocks = 0;
let convertedBlocks = 0;
let skippedBlocks = 0;
let errorBlocks = 0;
try {
const blocks = convertNodeArray(ast, config, warnings, errors, unsupportedBlocks);
// Count blocks
totalBlocks = countBlocks(ast);
convertedBlocks = blocks.length;
skippedBlocks = totalBlocks - convertedBlocks - errorBlocks;
const processingTime = Date.now() - startTime;
const metadata = {
sourceFormat: 'markdown',
targetFormat: 'notion',
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: blocks,
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: 'markdown',
targetFormat: 'notion',
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 an array of markdown nodes to Notion blocks
*/
function convertNodeArray(nodes, options, warnings, errors, unsupportedBlocks) {
const blocks = [];
for (const node of nodes) {
try {
const convertedBlocks = convertMarkdownNode(node, options, warnings, errors, unsupportedBlocks);
blocks.push(...convertedBlocks);
}
catch (error) {
errors.push(`Error converting node ${node.type}: ${error instanceof Error ? error.message : String(error)}`);
if (options.handleUnsupportedBlocks === 'convert') {
blocks.push(buildFallbackBlock(node.content ?? 'Error converting content', node.type));
}
else if (options.handleUnsupportedBlocks === 'error') {
throw new Error(`Failed to convert ${node.type}: ${error instanceof Error ? error.message : String(error)}`);
}
// 'ignore' option skips the block
}
}
return blocks;
}
/**
* Convert a single markdown node to Notion blocks
*/
function convertMarkdownNode(node, options, warnings, errors, unsupportedBlocks) {
switch (node.type) {
case 'heading':
return convertHeading(node, options, warnings);
case 'paragraph':
return convertParagraph(node, options);
case 'list':
return convertList(node, options, warnings, errors, unsupportedBlocks);
case 'list_item':
return convertListItem(node, options, warnings, errors, unsupportedBlocks);
case 'code':
return convertCodeBlock(node, options, errors);
case 'quote':
return convertQuote(node, options, warnings, errors, unsupportedBlocks);
case 'table':
return convertTable(node, options, warnings);
case 'image':
return convertImage(node, options, warnings);
case 'divider':
return [buildDividerBlock()];
default:
unsupportedBlocks.push(node.type);
warnings.push(`Unsupported block type: ${node.type}`);
if (options.handleUnsupportedBlocks === 'convert') {
return [buildFallbackBlock(node.content ?? 'Unsupported content', node.type)];
}
else if (options.handleUnsupportedBlocks === 'error') {
throw new Error(`Unsupported block type: ${node.type}`);
}
return []; // ignore
}
}
/**
* Convert heading node
*/
function convertHeading(node, options, _warnings) {
const level = node.level ?? 1;
// Silently normalize heading levels to Notion's supported range (H1-H3)
const notionLevel = normalizeHeadingLevel(Math.min(level, options.maxHeadingLevel));
return [buildHeadingBlock(node.children ?? node.content ?? '', notionLevel)];
}
/**
* Convert paragraph node
*/
function convertParagraph(node, options) {
const content = node.children ?? node.content ?? '';
// Skip empty paragraphs unless explicitly preserving formatting
if ((content === '' || content === null || content === undefined) && !options.preserveFormatting) {
return [];
}
// Handle line breaks - preserve them as separate paragraph blocks
if (typeof content === 'string' && content.includes('\n')) {
// Split on any line breaks and create separate paragraph blocks
const lines = content.split('\n');
const blocks = [];
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
blocks.push(buildParagraphBlock(trimmedLine));
}
}
return blocks.length > 0 ? blocks : [buildParagraphBlock(content)];
}
return [buildParagraphBlock(content)];
}
/**
* Convert list node
*/
function convertList(node, options, warnings, errors, unsupportedBlocks) {
if (!node.children) {
warnings.push('List node has no children');
return [];
}
const blocks = [];
for (const child of node.children) {
if (child.type === 'list_item') {
const childBlocks = convertListItem(child, options, warnings, errors, unsupportedBlocks, node.ordered === true ? 'numbered' : 'bulleted');
blocks.push(...childBlocks);
}
else {
warnings.push(`Non-list-item child in list: ${child.type}`);
}
}
return blocks;
}
/**
* Convert list item node
*/
function convertListItem(node, options, warnings, errors, unsupportedBlocks, listType) {
// Extract text content from children if available, otherwise use direct content
let content = '';
if (node.children !== null && node.children !== undefined && Array.isArray(node.children)) {
// Check if first child is a paragraph containing the text content
const firstChild = node.children[0];
if (firstChild !== null && firstChild !== undefined && firstChild.type === 'paragraph' && firstChild.children !== null && firstChild.children !== undefined) {
content = firstChild.children; // Use the paragraph's children for rich text
}
else {
// Extract text content from all text nodes
const textNodes = node.children.filter(child => child.type === 'text');
if (textNodes.length > 0) {
content = textNodes;
}
else {
content = node.content ?? '';
}
}
}
else {
content = node.content ?? '';
}
// Determine list type
let type = listType ?? 'bulleted';
// Check if it's a task list item
if (node.checked !== undefined) {
type = 'todo';
}
// Convert nested children (skip the first paragraph if it was used for content)
let childBlocks = [];
if (node.children && Array.isArray(node.children)) {
const blockChildren = node.children.filter((child, index) => {
// Skip first paragraph if it was used for content
if (index === 0 && child.type === 'paragraph' && Array.isArray(content)) {
return false;
}
// Include block-level children
return ['list', 'paragraph', 'code', 'quote'].includes(child.type);
});
if (blockChildren.length > 0) {
childBlocks = convertNodeArray(blockChildren, options, warnings, errors, unsupportedBlocks);
}
}
switch (type) {
case 'bulleted':
return [buildBulletedListItemBlock(content, childBlocks)];
case 'numbered':
return [buildNumberedListItemBlock(content, childBlocks)];
case 'todo':
return [buildToDoBlock(content, node.checked ?? false, childBlocks)];
default:
return [buildBulletedListItemBlock(content, childBlocks)];
}
}
/**
* Convert code block node
*/
function convertCodeBlock(node, options, errors) {
const content = node.content ?? '';
// Validate code block length against Notion's limit
if (content.length > NOTION_CODE_BLOCK_MAX_CHARACTERS) {
const error = `Code block exceeds Notion's ${NOTION_CODE_BLOCK_MAX_CHARACTERS} character limit: ${content.length} characters found. Language: ${node.language ?? 'none'}`;
errors.push(error);
throw new Error(error);
}
return [buildCodeBlock(content, node.language, undefined)];
}
/**
* Convert quote node
*/
function convertQuote(node, options, warnings, errors, unsupportedBlocks) {
const content = node.children ?? node.content ?? '';
// Convert nested children
let childBlocks = [];
if (node.children && Array.isArray(node.children)) {
childBlocks = convertNodeArray(node.children, options, warnings, errors, unsupportedBlocks);
}
return [buildQuoteBlock(content, childBlocks)];
}
/**
* Convert table node
*/
function convertTable(node, options, warnings) {
if (!node.children) {
warnings.push('Table node has no children');
return [];
}
const rows = [];
for (const rowNode of node.children) {
if (rowNode.type === 'table_row' && rowNode.children) {
const row = [];
for (const cellNode of rowNode.children) {
if (cellNode.type === 'table_cell') {
// Use children for rich text content, fallback to content for plain text
if (cellNode.children && cellNode.children.length > 0) {
row.push({
type: 'table_cell',
children: cellNode.children,
content: cellNode.content ?? '',
});
}
else {
row.push({
type: 'table_cell',
content: cellNode.content ?? '',
children: [],
});
}
}
}
if (row.length > 0) {
rows.push(row);
}
}
}
if (rows.length === 0) {
warnings.push('Table has no valid rows');
return [];
}
return buildTableBlockFromNodes(rows, options.tableAlignment);
}
/**
* Convert image node
*/
function convertImage(node, options, warnings) {
if (node.url === null || node.url === undefined || node.url === '') {
warnings.push('Image node missing URL');
return [];
}
let imageUrl = node.url;
// Handle relative URLs
if (!imageUrl.startsWith('http') && options.imageBaseUrl !== null && options.imageBaseUrl !== undefined && options.imageBaseUrl !== '') {
imageUrl = `${options.imageBaseUrl.replace(/\/$/, '')}/${imageUrl.replace(/^\//, '')}`;
}
// Handle image processing based on options
switch (options.imageHandling) {
case 'ignore':
return [];
case 'upload':
warnings.push('Image upload not implemented, using link');
return [buildImageBlock(imageUrl, node.alt)];
case 'link':
default:
return [buildImageBlock(imageUrl, node.alt)];
}
}
/**
* Count total nodes in AST (for statistics)
*/
function countBlocks(nodes) {
let count = 0;
for (const node of nodes) {
count++;
if (node.children) {
count += countBlocks(node.children);
}
}
return count;
}
//# sourceMappingURL=markdown-to-notion.js.map