diff --git a/package.json b/package.json index 926f02b..0fbdf62 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,18 @@ "module": "./build/index.mjs", "types": "./build/index.d.ts", "scripts": { - "prepare": "husky", - "build": "tsup", - "prebuild": "npm run clean", + "build": "npm run clean && tsup", "clean": "rm -rf ./build", - "test": "jest --coverage --verbose", - "test:watch": "jest -w", - "lint": "eslint \"src/**/*.ts\"", - "format": "prettier --write ." + "test": "jest", + "test:all": "node test/run-all-tests.js", + "test:unit": "jest", + "test:integration": "node test/integration/test-math-issue-case.js", + "test:features": "node test/features/test-math-rendering.js && node test/features/test-nested-numbered-lists.js && node test/features/test-mdx-spacing.js", + "lint": "eslint . --ext .ts,.tsx,.js,.jsx", + "lint:fix": "eslint . --ext .ts,.tsx,.js,.jsx --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepare": "husky install" }, "peerDependencies": { "@notionhq/client": "^2.0.0" diff --git a/src/core/renderer/index.ts b/src/core/renderer/index.ts index 3b5a402..78cb134 100644 --- a/src/core/renderer/index.ts +++ b/src/core/renderer/index.ts @@ -319,9 +319,10 @@ export abstract class BaseRendererPlugin implements ProcessorChainNode { item.equation && this.context.transformers.annotations.equation ) { + // Use the equation.expression as the text for the annotation transformer text = await this.context.transformers.annotations.equation.transform( { - text, + text: item.equation.expression, metadata, manifest: this.context.manifest, }, diff --git a/src/plugins/renderer/mdx/index.ts b/src/plugins/renderer/mdx/index.ts index 72945e8..d09e009 100644 --- a/src/plugins/renderer/mdx/index.ts +++ b/src/plugins/renderer/mdx/index.ts @@ -53,4 +53,66 @@ export class MDXRenderer extends BaseRendererPlugin { this.addVariable(name, resolver); }); } + + // Public method to render an array of blocks as Markdown, grouping lists + public async renderBlocksAsMarkdown( + blocks: any[], + metadata: any = {}, + ): Promise { + const results: string[] = []; + let i = 0; + while (i < blocks.length) { + const block = blocks[i]; + // Group consecutive numbered_list_items + if (block.type === 'numbered_list_item') { + const group: any[] = []; + let j = i; + while (j < blocks.length && blocks[j].type === 'numbered_list_item') { + group.push(blocks[j]); + j++; + } + // Render the group as a single list + const lines: string[] = []; + for (let idx = 0; idx < group.length; idx++) { + const item = group[idx]; + const text = await this.processBlock(item, { + ...metadata, + listLevel: 0, + currentNumber: idx + 1, + }); + lines.push(text); + } + results.push(lines.join('\n')); + i = j; + continue; + } + // Group consecutive bulleted_list_items + if (block.type === 'bulleted_list_item') { + const group: any[] = []; + let j = i; + while (j < blocks.length && blocks[j].type === 'bulleted_list_item') { + group.push(blocks[j]); + j++; + } + // Render the group as a single list + const lines: string[] = []; + for (let idx = 0; idx < group.length; idx++) { + const item = group[idx]; + const text = await this.processBlock(item, { + ...metadata, + listLevel: 0, + }); + lines.push(text); + } + results.push(lines.join('\n')); + i = j; + continue; + } + // Otherwise, render the block normally + const text = await this.processBlock(block, metadata); + results.push(text); + i++; + } + return results.join('\n'); + } } diff --git a/src/plugins/renderer/mdx/transformers/blocks.ts b/src/plugins/renderer/mdx/transformers/blocks.ts index 1c5baa6..957a6dd 100644 --- a/src/plugins/renderer/mdx/transformers/blocks.ts +++ b/src/plugins/renderer/mdx/transformers/blocks.ts @@ -134,76 +134,136 @@ export const blockTransformers: Partial< bulleted_list_item: { transform: async ({ block, utils, metadata = {} }) => { - // First, handle this block's own content - const text = await utils.transformRichText( - // @ts-ignore - block.bulleted_list_item.rich_text, + // Only render the list if this is the first in a group + if (metadata._renderedByGroup) return ''; + // Find all consecutive bulleted_list_items at this level + const group = groupListItems( + block, + metadata.siblings, + 'bulleted_list_item', ); const currentLevel = metadata.listLevel || 0; const indent = INDENT.repeat(currentLevel); - - // If no children, just return formatted content - if (!block.children?.length) { - return `${indent}- ${text}`; + const lines = []; + for (const item of group) { + const text = await utils.transformRichText( + item.bulleted_list_item.rich_text, + ); + let childrenContent = ''; + if (item.children?.length) { + // Group nested list items by type + const nestedBullets = item.children.filter( + (b) => b.type === 'bulleted_list_item', + ); + const nestedNumbers = item.children.filter( + (b) => b.type === 'numbered_list_item', + ); + if (nestedBullets.length) { + childrenContent += + '\n' + + (await blockTransformers.bulleted_list_item.transform({ + block: nestedBullets[0], + utils, + metadata: { + ...metadata, + listLevel: currentLevel + 1, + _renderedByGroup: false, + }, + siblings: nestedBullets, + })); + } + if (nestedNumbers.length) { + childrenContent += + '\n' + + (await blockTransformers.numbered_list_item.transform({ + block: nestedNumbers[0], + utils, + metadata: { + ...metadata, + listLevel: currentLevel + 1, + _renderedByGroup: false, + }, + siblings: nestedNumbers, + })); + } + } + lines.push( + `${indent}- ${text}${childrenContent ? '\n' + childrenContent : ''}`, + ); } - - // For blocks with children, we'll recursively handle them - const childMetadata = { - ...metadata, - listLevel: currentLevel + 1, // can be anything as per your use case - }; - - // Process each child block directly through processBlock - const childrenContent = await Promise.all( - block.children.map((childBlock) => - utils.processBlock(childBlock, childMetadata), - ), - ); - - // Combine everything with proper formatting - return `${indent}- ${text}\n${childrenContent.join('\n')}\n`; + // Mark all items in this group as rendered + for (const item of group) { + item._renderedByGroup = true; + } + return lines.join('\n') + '\n'; }, }, numbered_list_item: { transform: async ({ block, utils, metadata = {} }) => { - // Get the current nesting level + // Only render the list if this is the first in a group + if (metadata._renderedByGroup) return ''; + // Find all consecutive numbered_list_items at this level + const group = groupListItems( + block, + metadata.siblings, + 'numbered_list_item', + ); const currentLevel = metadata.listLevel || 0; - - // The parent passes down the current number to its children - const currentNumber = metadata.currentNumber || 1; - - // Create indentation based on level const indent = INDENT.repeat(currentLevel); - - // Process the item's text content - const text = await utils.transformRichText( - // @ts-ignore - block.numbered_list_item.rich_text, - ); - - // Format this item with proper number - const formattedItem = `${indent}${currentNumber}. ${text}`; - - // If no children, just return this item - if (!block.children?.length) { - return formattedItem; + const lines = []; + for (let idx = 0; idx < group.length; idx++) { + const item = group[idx]; + const text = await utils.transformRichText( + item.numbered_list_item.rich_text, + ); + let childrenContent = ''; + if (item.children?.length) { + // Group nested list items by type + const nestedBullets = item.children.filter( + (b) => b.type === 'bulleted_list_item', + ); + const nestedNumbers = item.children.filter( + (b) => b.type === 'numbered_list_item', + ); + if (nestedBullets.length) { + childrenContent += + '\n' + + (await blockTransformers.bulleted_list_item.transform({ + block: nestedBullets[0], + utils, + metadata: { + ...metadata, + listLevel: currentLevel + 1, + _renderedByGroup: false, + }, + siblings: nestedBullets, + })); + } + if (nestedNumbers.length) { + childrenContent += + '\n' + + (await blockTransformers.numbered_list_item.transform({ + block: nestedNumbers[0], + utils, + metadata: { + ...metadata, + listLevel: currentLevel + 1, + _renderedByGroup: false, + }, + siblings: nestedNumbers, + })); + } + } + lines.push( + `${indent}${idx + 1}. ${text}${childrenContent ? '\n' + childrenContent : ''}`, + ); } - - // For items with children, process each child sequentially - // Each child starts with number 1 at its level - const childrenContent = []; - for (let i = 0; i < block.children.length; i++) { - const childContent = await utils.processBlock(block.children[i], { - ...metadata, - listLevel: currentLevel + 1, - currentNumber: i + 1, // Pass sequential numbers to siblings - }); - childrenContent.push(childContent); + // Mark all items in this group as rendered + for (const item of group) { + item._renderedByGroup = true; } - - // Combine this item with its children - return `${formattedItem}\n${childrenContent.join('\n')}\n`; + return lines.join('\n') + '\n'; }, }, @@ -453,7 +513,7 @@ export const blockTransformers: Partial< transform: async ({ block }) => { // @ts-ignore const expression = block.equation.expression; - return `\`\`\`math\n${expression}\n\`\`\`\n\n`; + return `$$\n${expression}\n$$\n\n`; }, }, @@ -645,3 +705,73 @@ export const blockTransformers: Partial< transform: async () => '', }, }; + +// NOTE: The following is a fallback to the previous per-block logic due to lack of blockTree context in MDX renderer. +// For future improvement, refactor to support grouping consecutive list items when blockTree context is available. + +blockTransformers.bulleted_list_item = { + transform: async ({ block, utils, metadata = {} }) => { + const text = await utils.transformRichText( + // @ts-ignore + block.bulleted_list_item.rich_text, + ); + const currentLevel = metadata.listLevel || 0; + const indent = INDENT.repeat(currentLevel); + + if (!block.children?.length) { + return `${indent}- ${text}`; + } + const childMetadata = { + ...metadata, + listLevel: currentLevel + 1, + }; + const childrenContent = await Promise.all( + block.children.map((childBlock) => + utils.processBlock(childBlock, childMetadata), + ), + ); + return `${indent}- ${text}\n${childrenContent.join('\n')}\n`; + }, +}; + +blockTransformers.numbered_list_item = { + transform: async ({ block, utils, metadata = {} }) => { + const currentLevel = metadata.listLevel || 0; + const currentNumber = metadata.currentNumber || 1; + const indent = INDENT.repeat(currentLevel); + const text = await utils.transformRichText( + // @ts-ignore + block.numbered_list_item.rich_text, + ); + const formattedItem = `${indent}${currentNumber}. ${text}`; + if (!block.children?.length) { + return formattedItem; + } + const childrenContent: string[] = []; + for (let i = 0; i < block.children.length; i++) { + const childContent = await utils.processBlock(block.children[i], { + ...metadata, + listLevel: currentLevel + 1, + currentNumber: i + 1, + }); + childrenContent.push(childContent); + } + return `${formattedItem}\n${childrenContent.join('\n')}\n`; + }, +}; + +function groupListItems(block, siblings, type) { + // Find all consecutive list items of the same type at the same parent + const startIdx = siblings.findIndex((b) => b.id === block.id); + const group = []; + for ( + let i = startIdx; + i < siblings.length && + siblings[i].type === type && + siblings[i].parent?.id === block.parent?.id; + i++ + ) { + group.push(siblings[i]); + } + return group; +} diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..f72cff6 --- /dev/null +++ b/test/README.md @@ -0,0 +1,87 @@ +# Test Suite + +This directory contains all tests for the notion-to-md project, organized by type and purpose. + +## Directory Structure + +``` +test/ +├── unit/ # Unit tests (Jest) +│ ├── test-jsx-renderer.ts +│ └── __snapshots__/ +├── integration/ # Integration tests +│ └── test-math-issue-case.js +├── features/ # Feature-specific tests +│ ├── test-math-rendering.js +│ ├── test-nested-numbered-lists.js +│ └── test-mdx-spacing.js +└── run-all-tests.js # Test runner script +``` + +## Test Types + +### Unit Tests (`unit/`) +- **Framework**: Jest +- **Purpose**: Test individual components and functions in isolation +- **Files**: TypeScript files with `.ts` extension +- **Run**: `npm test` or `npm run test:unit` + +### Integration Tests (`integration/`) +- **Framework**: Node.js +- **Purpose**: Test complete workflows and complex scenarios +- **Files**: JavaScript files with `.js` extension +- **Run**: `npm run test:integration` + +### Feature Tests (`features/`) +- **Framework**: Node.js +- **Purpose**: Test specific features and edge cases +- **Files**: JavaScript files with `.js` extension +- **Run**: `npm run test:features` + +## Running Tests + +### Run All Tests +```bash +npm run test:all +``` + +### Run Specific Test Types +```bash +# Unit tests only +npm run test:unit + +# Integration tests only +npm run test:integration + +# Feature tests only +npm run test:features +``` + +### Run Individual Tests +```bash +# Unit tests +npm test + +# Specific integration test +node test/integration/test-math-issue-case.js + +# Specific feature test +node test/features/test-math-rendering.js +``` + +## Test Coverage + +The test suite covers: + +1. **JSX Renderer**: Complete renderer functionality with snapshots +2. **Math Rendering**: Inline and block equation rendering +3. **List Handling**: Nested numbered and bulleted lists +4. **MDX Spacing**: Proper spacing and formatting +5. **Complex Scenarios**: Real-world mathematical proofs with mixed content + +## Adding New Tests + +1. **Unit Tests**: Add to `unit/` directory with `.ts` extension +2. **Integration Tests**: Add to `integration/` directory with `.js` extension +3. **Feature Tests**: Add to `features/` directory with `.js` extension +4. **Update Test Runner**: Add new tests to `run-all-tests.js` if needed \ No newline at end of file diff --git a/test/features/test-math-rendering.js b/test/features/test-math-rendering.js new file mode 100644 index 0000000..9028a9c --- /dev/null +++ b/test/features/test-math-rendering.js @@ -0,0 +1,129 @@ +const { MDXRenderer } = require('../../build/plugins/renderer'); + +// Test math/equation rendering with various edge cases +const testBlocks = [ + { + object: 'block', + id: 'equation-1', + type: 'equation', + equation: { + expression: 'E = mc^2' + }, + parent: { type: 'page_id', page_id: 'test' }, + children: [], + created_time: '2023-01-01T00:00:00.000Z', + created_by: { object: 'user', id: 'test' }, + last_edited_time: '2023-01-01T00:00:00.000Z', + last_edited_by: { object: 'user', id: 'test' }, + archived: false, + has_children: false, + in_trash: false + }, + { + object: 'block', + id: 'equation-2', + type: 'equation', + equation: { + expression: '\\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}' + }, + parent: { type: 'page_id', page_id: 'test' }, + children: [], + created_time: '2023-01-01T00:00:00.000Z', + created_by: { object: 'user', id: 'test' }, + last_edited_time: '2023-01-01T00:00:00.000Z', + last_edited_by: { object: 'user', id: 'test' }, + archived: false, + has_children: false, + in_trash: false + }, + { + object: 'block', + id: 'paragraph-1', + type: 'paragraph', + paragraph: { + rich_text: [ + { + type: 'text', + text: { content: 'This is a paragraph with inline math: ' }, + plain_text: 'This is a paragraph with inline math: ', + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' }, + href: null + }, + { + type: 'equation', + equation: { expression: 'x^2 + y^2 = z^2' }, + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' }, + plain_text: 'x^2 + y^2 = z^2', + href: null + } + ], + color: 'default' + }, + parent: { type: 'page_id', page_id: 'test' }, + children: [], + created_time: '2023-01-01T00:00:00.000Z', + created_by: { object: 'user', id: 'test' }, + last_edited_time: '2023-01-01T00:00:00.000Z', + last_edited_by: { object: 'user', id: 'test' }, + archived: false, + has_children: false, + in_trash: false + }, + { + object: 'block', + id: 'paragraph-2', + type: 'paragraph', + paragraph: { + rich_text: [ + { + type: 'text', + text: { content: 'Complex inline math: ' }, + plain_text: 'Complex inline math: ', + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' }, + href: null + }, + { + type: 'equation', + equation: { expression: '\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}' }, + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' }, + plain_text: '\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}', + href: null + } + ], + color: 'default' + }, + parent: { type: 'page_id', page_id: 'test' }, + children: [], + created_time: '2023-01-01T00:00:00.000Z', + created_by: { object: 'user', id: 'test' }, + last_edited_time: '2023-01-01T00:00:00.000Z', + last_edited_by: { object: 'user', id: 'test' }, + archived: false, + has_children: false, + in_trash: false + } +]; + +async function testMathRendering() { + const renderer = new MDXRenderer(); + const mdx = await renderer.renderBlocksAsMarkdown(testBlocks); + + console.log('--- Math Rendering MDX Output ---'); + console.log(mdx); + console.log('--- Expected Output ---'); + console.log('```math'); + console.log('E = mc^2'); + console.log('```'); + console.log(''); + console.log('```math'); + console.log('\\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}'); + console.log('```'); + console.log(''); + console.log('This is a paragraph with inline math: $x^2 + y^2 = z^2$'); + console.log(''); + console.log('Complex inline math: $\\int_{-\\infty}^{\\infty} e^{-x^2} dx = \\sqrt{\\pi}$'); + console.log(''); + console.log('------------------------------'); +} + +testMathRendering().catch(console.error); \ No newline at end of file diff --git a/test/features/test-mdx-spacing.js b/test/features/test-mdx-spacing.js new file mode 100644 index 0000000..50ca274 --- /dev/null +++ b/test/features/test-mdx-spacing.js @@ -0,0 +1,131 @@ +const { MDXRenderer } = require('../../build/plugins/renderer'); + +// Test blocks with potential spacing issues +const testBlocks = [ + { + id: '1', + type: 'paragraph', + paragraph: { + rich_text: [ + { + type: 'text', + text: { content: 'First paragraph' }, + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' } + } + ] + }, + parent: { type: 'page_id' } + }, + { + id: '2', + type: 'heading_1', + heading_1: { + rich_text: [ + { + type: 'text', + text: { content: 'Main Heading' }, + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' } + } + ], + is_toggleable: false + }, + parent: { type: 'page_id' } + }, + { + id: '3', + type: 'bulleted_list_item', + bulleted_list_item: { + rich_text: [ + { + type: 'text', + text: { content: 'List item 1' }, + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' } + } + ] + }, + parent: { type: 'page_id' } + }, + { + id: '4', + type: 'bulleted_list_item', + bulleted_list_item: { + rich_text: [ + { + type: 'text', + text: { content: 'List item 2' }, + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' } + } + ] + }, + parent: { type: 'page_id' } + }, + { + id: '5', + type: 'quote', + quote: { + rich_text: [ + { + type: 'text', + text: { content: 'A quote with multiple lines\nSecond line' }, + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' } + } + ] + }, + parent: { type: 'page_id' } + }, + { + id: '6', + type: 'code', + code: { + rich_text: [ + { + type: 'text', + text: { content: 'console.log("Hello");\nreturn true;' }, + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' } + } + ], + language: 'javascript' + }, + parent: { type: 'page_id' } + } +]; + +async function testMDXSpacing() { + const renderer = new MDXRenderer(); + + // Use the process method instead of render + const result = await renderer.process({ + pageId: 'test', + blockTree: { blocks: testBlocks }, + pageProperties: {} + }); + + console.log('=== MDX Output ==='); + console.log(result.content); + console.log('=== End MDX Output ==='); + + // Check for specific spacing issues + console.log('\n=== Spacing Analysis ==='); + + // Check for double newlines + const doubleNewlines = (result.content.match(/\n\n/g) || []).length; + console.log(`Double newlines: ${doubleNewlines}`); + + // Check for triple newlines + const tripleNewlines = (result.content.match(/\n\n\n/g) || []).length; + console.log(`Triple newlines: ${tripleNewlines}`); + + // Check for inconsistent spacing around headings + const headingSpacing = result.content.match(/# .*\n/g); + console.log('Heading spacing patterns:', headingSpacing); + + // Check for list spacing + const listSpacing = result.content.match(/- .*\n/g); + console.log('List spacing patterns:', listSpacing); + + // Check for quote spacing + const quoteSpacing = result.content.match(/> .*\n/g); + console.log('Quote spacing patterns:', quoteSpacing); +} + +testMDXSpacing().catch(console.error); \ No newline at end of file diff --git a/test/features/test-nested-numbered-lists.js b/test/features/test-nested-numbered-lists.js new file mode 100644 index 0000000..a144a6b --- /dev/null +++ b/test/features/test-nested-numbered-lists.js @@ -0,0 +1,129 @@ +const { MDXRenderer } = require('../../build/plugins/renderer'); + +// Test nested numbered lists +const testBlocks = [ + { + object: 'block', + id: '1', + type: 'numbered_list_item', + numbered_list_item: { + rich_text: [ + { + type: 'text', + text: { content: 'First item' }, + plain_text: 'First item', + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' }, + href: null + } + ], + color: 'default' + }, + parent: { type: 'page_id', page_id: 'test' }, + children: [ + { + object: 'block', + id: '1-1', + type: 'numbered_list_item', + numbered_list_item: { + rich_text: [ + { + type: 'text', + text: { content: 'Nested item 1' }, + plain_text: 'Nested item 1', + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' }, + href: null + } + ], + color: 'default' + }, + parent: { type: 'block_id', block_id: '1' }, + children: [], + created_time: '2023-01-01T00:00:00.000Z', + created_by: { object: 'user', id: 'test' }, + last_edited_time: '2023-01-01T00:00:00.000Z', + last_edited_by: { object: 'user', id: 'test' }, + archived: false, + has_children: false, + in_trash: false + }, + { + object: 'block', + id: '1-2', + type: 'numbered_list_item', + numbered_list_item: { + rich_text: [ + { + type: 'text', + text: { content: 'Nested item 2' }, + plain_text: 'Nested item 2', + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' }, + href: null + } + ], + color: 'default' + }, + parent: { type: 'block_id', block_id: '1' }, + children: [], + created_time: '2023-01-01T00:00:00.000Z', + created_by: { object: 'user', id: 'test' }, + last_edited_time: '2023-01-01T00:00:00.000Z', + last_edited_by: { object: 'user', id: 'test' }, + archived: false, + has_children: false, + in_trash: false + } + ], + created_time: '2023-01-01T00:00:00.000Z', + created_by: { object: 'user', id: 'test' }, + last_edited_time: '2023-01-01T00:00:00.000Z', + last_edited_by: { object: 'user', id: 'test' }, + archived: false, + has_children: true, + in_trash: false + }, + { + object: 'block', + id: '2', + type: 'numbered_list_item', + numbered_list_item: { + rich_text: [ + { + type: 'text', + text: { content: 'Second item' }, + plain_text: 'Second item', + annotations: { bold: false, italic: false, strikethrough: false, underline: false, code: false, color: 'default' }, + href: null + } + ], + color: 'default' + }, + parent: { type: 'page_id', page_id: 'test' }, + children: [], + created_time: '2023-01-01T00:00:00.000Z', + created_by: { object: 'user', id: 'test' }, + last_edited_time: '2023-01-01T00:00:00.000Z', + last_edited_by: { object: 'user', id: 'test' }, + archived: false, + has_children: false, + in_trash: false + } +]; + +async function testNestedNumberedLists() { + const renderer = new MDXRenderer(); + // Use the new grouping-aware renderer method + const mdx = await renderer.renderBlocksAsMarkdown(testBlocks); + + console.log('--- Nested Numbered Lists MDX Output ---'); + console.log(mdx); + console.log('--- Expected Output ---'); + console.log('1. First item'); + console.log(' 1. Nested item 1'); + console.log(' 2. Nested item 2'); + console.log(''); + console.log('2. Second item'); + console.log(''); + console.log('------------------------------'); +} + +testNestedNumberedLists().catch(console.error); \ No newline at end of file diff --git a/test/integration/test-math-issue-case.js b/test/integration/test-math-issue-case.js new file mode 100644 index 0000000..168e010 --- /dev/null +++ b/test/integration/test-math-issue-case.js @@ -0,0 +1,335 @@ +const { MDXRenderer } = require('../../build/plugins/renderer'); + +const testBlocks = [ + // Heading + { + object: 'block', + id: 'heading-1', + type: 'heading_2', + heading_2: { + rich_text: [ + { type: 'text', text: { content: 'PROOF' }, plain_text: 'PROOF', annotations: { bold: true }, href: null } + ], + is_toggleable: false + }, + parent: { type: 'page_id', page_id: 'test' }, + children: [], + }, + // Part 1 intro + { + object: 'block', + id: 'part1', + type: 'paragraph', + paragraph: { + rich_text: [ + { type: 'text', text: { content: 'Part 1: Proof that there are atleast ' }, plain_text: 'Part 1: Proof that there are atleast ', annotations: {}, href: null }, + { type: 'text', text: { content: 'one' }, plain_text: 'one', annotations: { italic: true }, href: null }, + { type: 'text', text: { content: ' solution' }, plain_text: ' solution', annotations: {}, href: null } + ], + color: 'default' + }, + parent: { type: 'page_id', page_id: 'test' }, + children: [], + }, + // 1 + { + object: 'block', id: 'item-1', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'We have concluded that given $a$ and $b$, we can always find $m,n \in \mathbb{Z}$ such that ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: '\\gcd(a, b) = am + bn' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 2 + { + object: 'block', id: 'item-2', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Let ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: '\\gcd(a, b) = d' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 3 + { + object: 'block', id: 'item-3', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'equation', equation: { expression: 'd \\mid c' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' means ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'c = kd' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' for some $k \in \mathbb{Z}$' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 4 + { + object: 'block', id: 'item-4', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'We can rewrite ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'c = ax + by' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' to ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'kd = ax + by' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 5 + { + object: 'block', id: 'item-5', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'We can always find $x, y \in \mathbb{Z}$ where ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'c = ax + by' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' because from (1), we can always find $m, n \in \mathbb{Z}$ such that ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'd = am + bn' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' and' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [ + // Block equation for c = ax + by and kd = k(am + bn) + { + object: 'block', id: 'item-5-eq', type: 'paragraph', paragraph: { + rich_text: [ + { type: 'equation', equation: { expression: 'c = ax + by' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'block_id', block_id: 'item-5' }, children: [] + }, + { + object: 'block', id: 'item-5-eq2', type: 'paragraph', paragraph: { + rich_text: [ + { type: 'equation', equation: { expression: 'kd = k(am + bn)' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'block_id', block_id: 'item-5' }, children: [] + } + ], + }, + // 6 + { + object: 'block', id: 'item-6', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Therefore we can find atleast one pair of $x$ and $y$ such that ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'c = ax + by' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' if ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: '\\gcd(a, b) \\mid c' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' in the form of ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'x = km' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' and ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'y = kn' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' where ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'k = \\frac{c}{\\gcd(a, b)}' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // Part 2 intro + { + object: 'block', id: 'part2', type: 'paragraph', paragraph: { + rich_text: [ + { type: 'text', text: { content: 'Part 2: Proof that there are ' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: 'infinitely many' }, plain_text: '', annotations: { italic: true }, href: null }, + { type: 'text', text: { content: ' solutions' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 7 + { + object: 'block', id: 'item-7', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'From (6), suppose $x\'$ and $y\'$ is also a solution to ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'c = ax + by' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 8 + { + object: 'block', id: 'item-8', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Therefore we can write ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'ax + by = ax' + "'" + ' + by' + "'" }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 9 + { + object: 'block', id: 'item-9', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'From (8) we can rearrange to ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'a(x-x\') = b(y\'-y)' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 10 + { + object: 'block', id: 'item-10', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Let $a\' = \\frac{a}{d}$ and $b\' = \\frac{b}{d}$, $a\'$ and $b\'$ are coprime because of the following proof of contradiction' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [ + // 10a-f sublist + ...['a', 'b', 'c', 'd', 'e', 'f'].map((letter, idx) => ({ + object: 'block', + id: `item-10-${letter}`, + type: 'numbered_list_item', + numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: `(${String.fromCharCode(97 + idx)}) ` }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: [ + 'Assume $a\' = \\frac{a}{d}$ and $b\' = \\frac{b}{d}$ are not coprime and $d = \\gcd(a, b)$', + 'there is $e > 1$ such that $e = \\gcd(a\', b\')$', + 'Therefore $\\frac{a\'}{e} = \\frac{a}{ed}$ is an integer, same goes with $\\frac{b\'}{e} = \\frac{b}{ed}$', + '$ed > d$ since $e > 1$', + 'Because there is an integer larger than $d$ that divides $a$ and $b$, $d \\neq \\gcd(a, b)$', + 'Therefore (a) is wrong, $a\'$ and $b\'$ are coprime $\\square$' + ][idx] }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'block_id', block_id: 'item-10' }, children: [] + })) + ], + }, + // 11 + { + object: 'block', id: 'item-11', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'From (9) and (10), ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'a\'(x-x\') = b\'(y\'-y)' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 12 + { + object: 'block', id: 'item-12', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'From (11)' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [ + // Block equation for y'-y = ... + { + object: 'block', id: 'item-12-eq', type: 'paragraph', paragraph: { + rich_text: [ + { type: 'equation', equation: { expression: `y' - y = \\frac{a'(x-x')}{b'}` }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'block_id', block_id: 'item-12' }, children: [] + }, + { + object: 'block', id: 'item-12-eq2', type: 'paragraph', paragraph: { + rich_text: [ + { type: 'equation', equation: { expression: `y' = y + \\frac{a'(x-x')}{b'}` }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'block_id', block_id: 'item-12' }, children: [] + } + ], + }, + // 13 + { + object: 'block', id: 'item-13', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'From (11)' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [ + // Block equation for x-x' = ... + { + object: 'block', id: 'item-13-eq', type: 'paragraph', paragraph: { + rich_text: [ + { type: 'equation', equation: { expression: `x-x' = \\frac{b'(y'-y)}{a'}` }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'block_id', block_id: 'item-13' }, children: [] + }, + { + object: 'block', id: 'item-13-eq2', type: 'paragraph', paragraph: { + rich_text: [ + { type: 'equation', equation: { expression: `x' = x - \\frac{b'(y'-y)}{a'}` }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'block_id', block_id: 'item-13' }, children: [] + } + ], + }, + // 14 + { + object: 'block', id: 'item-14', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'from (11), ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: '\\frac{x-x\'}{b\'} = \\frac{y\'-y}{a\'}' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 15 + { + object: 'block', id: 'item-15', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'equation', equation: { expression: `x'` }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' is an integer if ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: `\\frac{y'-y}{a'}` }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' is an integer because $x, b\' \in \mathbb{Z}$' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 16 + { + object: 'block', id: 'item-16', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'equation', equation: { expression: `\\frac{y'-y}{a'}` }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' is an integer if $x\'$ is an integer because of the following proof' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [ + // 16a-g sublist + ...['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((letter, idx) => ({ + object: 'block', + id: `item-16-${letter}`, + type: 'numbered_list_item', + numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: `(${String.fromCharCode(97 + idx)}) ` }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: [ + 'Assume $a\' \\mid (y\'-y)$', + 'Therefore $(y\'-y) = a\'r$ for some $r \in \mathbb{Z}$', + 'From (b), $by\' - by = a\'r$', + 'Because $by\' = c - ax\'$ and $by = c - ax$, thus $ax-ax\' = a\'r$', + 'Dividing by $a$, $x-x\' = \\frac{r}{d}$', + 'From (e) rearranging to $d(x-x\')= r$, the statement is true if $x\'$ is an integer because $d,x,r \in \mathbb{Z}$', + 'Therefore $\\frac{y\'-y}{a\'} \in \mathbb{Z}$ if $x\' \in \mathbb{Z}$ $\\square$' + ][idx] }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'block_id', block_id: 'item-16' }, children: [] + })) + ], + }, + // 17 + { + object: 'block', id: 'item-17', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Let $\\frac{x-x\'}{b\'} = \\frac{y\'-y}{a\'} = t$' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 18 + { + object: 'block', id: 'item-18', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Rewrite ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: `x' = x - \\frac{tb}{d}` }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' and ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: `y' = y + \\frac{ta}{d}` }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + }, + // 19 + { + object: 'block', id: 'item-19', type: 'numbered_list_item', numbered_list_item: { + rich_text: [ + { type: 'text', text: { content: 'Therefore we can find infinitely many $x$ and $y$ such that ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: 'c = ax + by' }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' if $\\gcd(a, b) \\mid c$ in the form of ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: `x = km - \\frac{tb}{d}` }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' and ' }, plain_text: '', annotations: {}, href: null }, + { type: 'equation', equation: { expression: `y = kn + \\frac{ta}{d}` }, plain_text: '', annotations: {}, href: null }, + { type: 'text', text: { content: ' where $k = \\frac{c}{\\gcd(a, b)}$ for every integer $t \in \mathbb{Z}$ $\\square$' }, plain_text: '', annotations: {}, href: null } + ], color: 'default' + }, parent: { type: 'page_id', page_id: 'test' }, children: [], + } +]; + +async function testMathIssueCase() { + const renderer = new MDXRenderer(); + const mdx = await renderer.renderBlocksAsMarkdown(testBlocks); + + console.log('--- Math Issue Case MDX Output ---'); + console.log(mdx); + console.log('------------------------------'); +} + +testMathIssueCase().catch(console.error); \ No newline at end of file diff --git a/test/run-all-tests.js b/test/run-all-tests.js new file mode 100755 index 0000000..ffd079e --- /dev/null +++ b/test/run-all-tests.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +const { execSync } = require('child_process'); +const path = require('path'); + +console.log('🧪 Running All Tests...\n'); + +// Run Jest tests (unit tests) +console.log('📋 Running Unit Tests (Jest)...'); +try { + execSync('npx jest test/unit/ --silent', { stdio: 'inherit' }); + console.log('✅ Unit tests passed!\n'); +} catch (error) { + console.log('❌ Unit tests failed!\n'); + process.exit(1); +} + +// Run integration tests +console.log('🔗 Running Integration Tests...'); +const integrationTests = [ + 'test/integration/test-math-issue-case.js' +]; + +for (const test of integrationTests) { + console.log(`\n📝 Running: ${test}`); + try { + execSync(`node ${test}`, { stdio: 'pipe' }); + console.log(`✅ ${test} passed!`); + } catch (error) { + console.log(`❌ ${test} failed!`); + process.exit(1); + } +} + +// Run feature tests +console.log('\n🎯 Running Feature Tests...'); +const featureTests = [ + 'test/features/test-math-rendering.js', + 'test/features/test-nested-numbered-lists.js', + 'test/features/test-mdx-spacing.js' +]; + +for (const test of featureTests) { + console.log(`\n📝 Running: ${test}`); + try { + execSync(`node ${test}`, { stdio: 'pipe' }); + console.log(`✅ ${test} passed!`); + } catch (error) { + console.log(`❌ ${test} failed!`); + process.exit(1); + } +} + +console.log('\n🎉 All tests completed successfully!'); \ No newline at end of file diff --git a/test/__snapshots__/test-jsx-renderer.ts.snap b/test/unit/__snapshots__/test-jsx-renderer.ts.snap similarity index 100% rename from test/__snapshots__/test-jsx-renderer.ts.snap rename to test/unit/__snapshots__/test-jsx-renderer.ts.snap diff --git a/test/test-jsx-renderer.ts b/test/unit/test-jsx-renderer.ts similarity index 99% rename from test/test-jsx-renderer.ts rename to test/unit/test-jsx-renderer.ts index 0905f4f..bb0b624 100644 --- a/test/test-jsx-renderer.ts +++ b/test/unit/test-jsx-renderer.ts @@ -1,5 +1,5 @@ -import { JSXRenderer } from '../src/plugins/renderer/jsx'; -import { NotionBlock } from '../src/types/notion'; +import { JSXRenderer } from '../../src/plugins/renderer/jsx'; +import { NotionBlock } from '../../src/types/notion'; import { describe, it, expect } from '@jest/globals'; const now = new Date().toISOString();