diff --git a/src/__tests__/new-features-validation.test.ts b/src/__tests__/new-features-validation.test.ts index 7707da6..0e9f7c1 100644 --- a/src/__tests__/new-features-validation.test.ts +++ b/src/__tests__/new-features-validation.test.ts @@ -270,20 +270,22 @@ describe('NEW FEATURES - Previously non-working elements', () => { const dataRow1 = result.content[0].content[1]; const dataRow2 = result.content[0].content[2]; - // First data row - user mention (directly in table cell) - expect(dataRow1.content[0].content[0].type).toBe('mention'); - expect(dataRow1.content[0].content[0].attrs.id).toBe('alice'); - - // First data row - status (directly in table cell) - expect(dataRow1.content[1].content[0].type).toBe('status'); - expect(dataRow1.content[1].content[0].attrs.text).toBe('Active'); - - // First data row - emoji (table cell contains text + emoji) - const notesCell = dataRow1.content[2].content; + // First data row - user mention (cell content wrapped in paragraph per ADF spec) + expect(dataRow1.content[0].content[0].type).toBe('paragraph'); + expect(dataRow1.content[0].content[0].content[0].type).toBe('mention'); + expect(dataRow1.content[0].content[0].content[0].attrs.id).toBe('alice'); + + // First data row - status (cell content wrapped in paragraph per ADF spec) + expect(dataRow1.content[1].content[0].type).toBe('paragraph'); + expect(dataRow1.content[1].content[0].content[0].type).toBe('status'); + expect(dataRow1.content[1].content[0].content[0].attrs.text).toBe('Active'); + + // First data row - emoji (table cell contains text + emoji, wrapped in paragraph) + const notesCell = dataRow1.content[2].content[0].content; expect(notesCell.some((node: any) => node.type === 'emoji')).toBeTruthy(); - - // Second data row - date (table cell contains text + date) - const notesCell2 = dataRow2.content[2].content; + + // Second data row - date (table cell contains text + date, wrapped in paragraph) + const notesCell2 = dataRow2.content[2].content[0].content; expect(notesCell2.some((node: any) => node.type === 'date')).toBeTruthy(); }); diff --git a/src/__tests__/table-panel-conversion.test.ts b/src/__tests__/table-panel-conversion.test.ts index fd05f2e..6f9e579 100644 --- a/src/__tests__/table-panel-conversion.test.ts +++ b/src/__tests__/table-panel-conversion.test.ts @@ -1,19 +1,25 @@ /** * @file table-panel-conversion.test.ts - * @description Comprehensive tests for table and panel conversion to verify proper ADF node generation + * @description Comprehensive tests for table and panel conversion to verify proper ADF node generation. + * Uses ASTBuilder + MarkdownTokenizer directly to avoid ESM-only unified dependency. */ -import { Parser } from '../index.js'; +import { ASTBuilder } from '../parser/markdown-to-adf/ASTBuilder.js'; +import { MarkdownTokenizer } from '../parser/markdown-to-adf/MarkdownTokenizer.js'; -describe('Table and Panel Conversion Tests', () => { - let parser: Parser; - - beforeEach(() => { - parser = new Parser({ enableAdfExtensions: true }); - }); +/** + * Helper: tokenize markdown and build ADF document using the direct tokenizer path. + */ +function parseMarkdown(markdown: string) { + const tokenizer = new MarkdownTokenizer(); + const builder = new ASTBuilder(); + const tokens = tokenizer.tokenize(markdown); + return builder.buildADF(tokens); +} +describe('Table and Panel Conversion Tests', () => { describe('Table Conversion', () => { - it('should convert simple markdown table to proper ADF table node', async () => { + it('should convert simple markdown table to proper ADF table node', () => { const markdown = ` | Column 1 | Column 2 | |----------|----------| @@ -21,171 +27,208 @@ describe('Table and Panel Conversion Tests', () => { | Data 3 | Data 4 | `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + // Should have one table node expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe('table'); - + const table = result.content[0]; - + // Table should have 3 rows (header + 2 data rows) expect(table.content).toHaveLength(3); - + // First row should be table headers const headerRow = table.content[0]; expect(headerRow.type).toBe('tableRow'); expect(headerRow.content).toHaveLength(2); expect(headerRow.content[0].type).toBe('tableHeader'); expect(headerRow.content[1].type).toBe('tableHeader'); - - // Check header content - expect(headerRow.content[0].content[0].text).toBe('Column 1'); - expect(headerRow.content[1].content[0].text).toBe('Column 2'); - + + // Check header content - cells wrap inline content in paragraph nodes per ADF spec + expect(headerRow.content[0].content[0].type).toBe('paragraph'); + expect(headerRow.content[0].content[0].content[0].text).toBe('Column 1'); + expect(headerRow.content[1].content[0].type).toBe('paragraph'); + expect(headerRow.content[1].content[0].content[0].text).toBe('Column 2'); + // Data rows should be table cells const dataRow1 = table.content[1]; expect(dataRow1.type).toBe('tableRow'); expect(dataRow1.content[0].type).toBe('tableCell'); expect(dataRow1.content[1].type).toBe('tableCell'); - - // Check data content - expect(dataRow1.content[0].content[0].text).toBe('Data 1'); - expect(dataRow1.content[1].content[0].text).toBe('Data 2'); + + // Check data content - cells wrap inline content in paragraph nodes per ADF spec + expect(dataRow1.content[0].content[0].type).toBe('paragraph'); + expect(dataRow1.content[0].content[0].content[0].text).toBe('Data 1'); + expect(dataRow1.content[1].content[0].type).toBe('paragraph'); + expect(dataRow1.content[1].content[0].content[0].text).toBe('Data 2'); }); - it('should NOT convert table to paragraph node with raw text', async () => { + it('should wrap table cell content in paragraph nodes per ADF spec', () => { + const markdown = ` +| a | b | +|---|---| +| c | | + `.trim(); + + const result = parseMarkdown(markdown); + const table = result.content[0]; + + // Header cells should contain paragraph children + const headerRow = table.content[0]; + expect(headerRow.content[0].type).toBe('tableHeader'); + expect(headerRow.content[0].content[0].type).toBe('paragraph'); + expect(headerRow.content[0].content[0].content[0].type).toBe('text'); + + expect(headerRow.content[1].type).toBe('tableHeader'); + expect(headerRow.content[1].content[0].type).toBe('paragraph'); + expect(headerRow.content[1].content[0].content[0].type).toBe('text'); + + // Data cells should contain paragraph children + const dataRow = table.content[1]; + expect(dataRow.content[0].type).toBe('tableCell'); + expect(dataRow.content[0].content[0].type).toBe('paragraph'); + expect(dataRow.content[0].content[0].content[0].text).toBe('c'); + + // Empty cells should have paragraph with empty content + expect(dataRow.content[1].type).toBe('tableCell'); + expect(dataRow.content[1].content[0].type).toBe('paragraph'); + }); + + it('should NOT convert table to paragraph node with raw text', () => { const markdown = ` | Component | Version | |-----------|---------| | Node.js | 18.0.0+ | `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + // Should be a table node, not a paragraph expect(result.content[0].type).not.toBe('paragraph'); expect(result.content[0].type).toBe('table'); - + // Should not contain raw markdown text const tableNode = result.content[0]; const hasRawMarkdown = JSON.stringify(tableNode).includes('|'); expect(hasRawMarkdown).toBe(false); }); - it('should handle table with basic content structure', async () => { + it('should handle table with basic content structure', () => { const markdown = ` | Header 1 | Header 2 | Header 3 | |----------|----------|----------| | Data 1 | Data 2 | Data 3 | `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + expect(result.content[0].type).toBe('table'); const table = result.content[0]; - + // Check that table has proper structure expect(table.content).toHaveLength(2); // header row + data row const headerRow = table.content[0]; const dataRow = table.content[1]; - + // Check header row expect(headerRow.content).toHaveLength(3); expect(headerRow.content[0].type).toBe('tableHeader'); - expect(headerRow.content[0].content[0].text).toBe('Header 1'); - - // Check data row + expect(headerRow.content[0].content[0].type).toBe('paragraph'); + expect(headerRow.content[0].content[0].content[0].text).toBe('Header 1'); + + // Check data row expect(dataRow.content).toHaveLength(3); expect(dataRow.content[0].type).toBe('tableCell'); - expect(dataRow.content[0].content[0].text).toBe('Data 1'); + expect(dataRow.content[0].content[0].type).toBe('paragraph'); + expect(dataRow.content[0].content[0].content[0].text).toBe('Data 1'); }); }); describe('Panel Conversion', () => { - it('should convert info panel to proper ADF panel node', async () => { + it('should convert info panel to proper ADF panel node', () => { const markdown = ` ~~~panel type=info title="Information" This is an info panel with important information. ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + // Should have one panel node expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe('panel'); - + const panel = result.content[0]; - + // Check panel attributes expect(panel.attrs).toBeDefined(); expect(panel.attrs.panelType).toBe('info'); - + // Check panel content expect(panel.content).toHaveLength(1); expect(panel.content[0].type).toBe('paragraph'); expect(panel.content[0].content[0].text).toContain('This is an info panel'); }); - it('should convert warning panel to proper ADF panel node', async () => { + it('should convert warning panel to proper ADF panel node', () => { const markdown = ` ~~~panel type=warning title="Warning" This is a warning message. ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + expect(result.content[0].type).toBe('panel'); expect(result.content[0].attrs.panelType).toBe('warning'); }); - it('should convert success panel to proper ADF panel node', async () => { + it('should convert success panel to proper ADF panel node', () => { const markdown = ` ~~~panel type=success title="Success" Operation completed successfully! ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + expect(result.content[0].type).toBe('panel'); expect(result.content[0].attrs.panelType).toBe('success'); }); - it('should convert error panel to proper ADF panel node', async () => { + it('should convert error panel to proper ADF panel node', () => { const markdown = ` ~~~panel type=error title="Error" Something went wrong! ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + expect(result.content[0].type).toBe('panel'); expect(result.content[0].attrs.panelType).toBe('error'); }); - it('should NOT convert panel to codeBlock node', async () => { + it('should NOT convert panel to codeBlock node', () => { const markdown = ` ~~~panel type=info title="Test" This should be a panel, not a code block. ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + // Should be a panel node, not a codeBlock expect(result.content[0].type).not.toBe('codeBlock'); expect(result.content[0].type).toBe('panel'); - + // Should not have language property (which codeBlock would have) expect(result.content[0].attrs).not.toHaveProperty('language'); expect(result.content[0].attrs).toHaveProperty('panelType'); }); - it('should handle panel with multiple paragraphs', async () => { + it('should handle panel with multiple paragraphs', () => { const markdown = ` ~~~panel type=note title="Multiple Paragraphs" First paragraph in the panel. @@ -194,8 +237,8 @@ Second paragraph in the panel. ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + const panel = result.content[0]; expect(panel.type).toBe('panel'); expect(panel.content.length).toBeGreaterThan(1); // Multiple paragraphs @@ -203,37 +246,36 @@ Second paragraph in the panel. }); describe('Expand Section Conversion', () => { - it('should convert expand section to proper ADF expand node', async () => { + it('should convert expand section to proper ADF expand node', () => { const markdown = ` ~~~expand title="Click to expand" Hidden content goes here. ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + expect(result.content[0].type).toBe('expand'); expect(result.content[0].attrs.title).toBe('Click to expand'); }); - it('should NOT convert expand to codeBlock node', async () => { + it('should NOT convert expand to codeBlock node', () => { const markdown = ` ~~~expand title="Troubleshooting" Detailed troubleshooting steps. ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + expect(result.content[0].type).not.toBe('codeBlock'); expect(result.content[0].type).toBe('expand'); }); }); describe('Mixed Content Conversion', () => { - it('should handle document with both tables and panels', async () => { - const markdown = ` -# Requirements + it('should handle document with both tables and panels', () => { + const markdown = `# Requirements ~~~panel type=info title="Prerequisites" Before installing, ensure your system meets these requirements. @@ -246,31 +288,228 @@ Before installing, ensure your system meets these requirements. ~~~panel type=success title="Success" Installation completed successfully! -~~~ - `.trim(); +~~~`; + + const result = parseMarkdown(markdown); - const result = await parser.markdownToAdf(markdown); - // Should have multiple nodes: heading, panel, table, panel expect(result.content.length).toBeGreaterThan(3); - + // Find and verify each type const heading = result.content.find((node: any) => node.type === 'heading'); const panels = result.content.filter((node: any) => node.type === 'panel'); const table = result.content.find((node: any) => node.type === 'table'); - + expect(heading).toBeDefined(); expect(panels).toHaveLength(2); expect(table).toBeDefined(); - + // Verify panel types expect(panels[0].attrs.panelType).toBe('info'); expect(panels[1].attrs.panelType).toBe('success'); }); }); + describe('Table inside Panel (parseTableFromLines code path)', () => { + it('should wrap table cell content in paragraph nodes when table is inside a panel', () => { + const markdown = `~~~panel type=info title="Details" +| Header | Value | +|--------|-------| +| key | val | +~~~`; + + const result = parseMarkdown(markdown); + + // Top-level node should be a panel + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('panel'); + + const panel = result.content[0]; + + // Panel content should contain a table + const table = panel.content.find((node: any) => node.type === 'table'); + expect(table).toBeDefined(); + expect(table.content).toHaveLength(2); // header row + data row + + // Header row + const headerRow = table.content[0]; + expect(headerRow.type).toBe('tableRow'); + expect(headerRow.content).toHaveLength(2); + + // Header cells should be wrapped in paragraph nodes + expect(headerRow.content[0].type).toBe('tableHeader'); + expect(headerRow.content[0].content[0].type).toBe('paragraph'); + expect(headerRow.content[0].content[0].content[0].text).toBe('Header'); + + expect(headerRow.content[1].type).toBe('tableHeader'); + expect(headerRow.content[1].content[0].type).toBe('paragraph'); + expect(headerRow.content[1].content[0].content[0].text).toBe('Value'); + + // Data row + const dataRow = table.content[1]; + expect(dataRow.type).toBe('tableRow'); + expect(dataRow.content).toHaveLength(2); + + // Data cells should be wrapped in paragraph nodes + expect(dataRow.content[0].type).toBe('tableCell'); + expect(dataRow.content[0].content[0].type).toBe('paragraph'); + expect(dataRow.content[0].content[0].content[0].text).toBe('key'); + + expect(dataRow.content[1].type).toBe('tableCell'); + expect(dataRow.content[1].content[0].type).toBe('paragraph'); + expect(dataRow.content[1].content[0].content[0].text).toBe('val'); + }); + + it('should wrap table cell content in paragraph nodes for multi-row table inside a panel', () => { + const markdown = `~~~panel type=warning title="Status" +| Service | Status | Uptime | +|---------|---------|--------| +| API | Running | 99.9% | +| DB | Stopped | 0% | +~~~`; + + const result = parseMarkdown(markdown); + + const panel = result.content[0]; + expect(panel.type).toBe('panel'); + + const table = panel.content.find((node: any) => node.type === 'table'); + expect(table).toBeDefined(); + expect(table.content).toHaveLength(3); // header + 2 data rows + + // Verify all header cells have paragraph wrapping + const headerRow = table.content[0]; + expect(headerRow.content).toHaveLength(3); + for (const headerCell of headerRow.content) { + expect(headerCell.type).toBe('tableHeader'); + expect(headerCell.content[0].type).toBe('paragraph'); + } + expect(headerRow.content[0].content[0].content[0].text).toBe('Service'); + expect(headerRow.content[1].content[0].content[0].text).toBe('Status'); + expect(headerRow.content[2].content[0].content[0].text).toBe('Uptime'); + + // Verify all data cells have paragraph wrapping + const dataRow1 = table.content[1]; + for (const cell of dataRow1.content) { + expect(cell.type).toBe('tableCell'); + expect(cell.content[0].type).toBe('paragraph'); + } + expect(dataRow1.content[0].content[0].content[0].text).toBe('API'); + expect(dataRow1.content[1].content[0].content[0].text).toBe('Running'); + expect(dataRow1.content[2].content[0].content[0].text).toBe('99.9%'); + + const dataRow2 = table.content[2]; + for (const cell of dataRow2.content) { + expect(cell.type).toBe('tableCell'); + expect(cell.content[0].type).toBe('paragraph'); + } + expect(dataRow2.content[0].content[0].content[0].text).toBe('DB'); + expect(dataRow2.content[1].content[0].content[0].text).toBe('Stopped'); + expect(dataRow2.content[2].content[0].content[0].text).toBe('0%'); + }); + }); + + describe('Table inside Expand (parseTableFromLines code path)', () => { + it('should wrap table cell content in paragraph nodes when table is inside an expand', () => { + const markdown = `~~~expand title="Click to expand" +| Header | Value | +|--------|-------| +| key | val | +~~~`; + + const result = parseMarkdown(markdown); + + // Top-level node should be an expand + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe('expand'); + + const expand = result.content[0]; + + // Expand content should contain a table + const table = expand.content.find((node: any) => node.type === 'table'); + expect(table).toBeDefined(); + expect(table.content).toHaveLength(2); // header row + data row + + // Header row + const headerRow = table.content[0]; + expect(headerRow.type).toBe('tableRow'); + expect(headerRow.content).toHaveLength(2); + + // Header cells should be wrapped in paragraph nodes + expect(headerRow.content[0].type).toBe('tableHeader'); + expect(headerRow.content[0].content[0].type).toBe('paragraph'); + expect(headerRow.content[0].content[0].content[0].text).toBe('Header'); + + expect(headerRow.content[1].type).toBe('tableHeader'); + expect(headerRow.content[1].content[0].type).toBe('paragraph'); + expect(headerRow.content[1].content[0].content[0].text).toBe('Value'); + + // Data row + const dataRow = table.content[1]; + expect(dataRow.type).toBe('tableRow'); + expect(dataRow.content).toHaveLength(2); + + // Data cells should be wrapped in paragraph nodes + expect(dataRow.content[0].type).toBe('tableCell'); + expect(dataRow.content[0].content[0].type).toBe('paragraph'); + expect(dataRow.content[0].content[0].content[0].text).toBe('key'); + + expect(dataRow.content[1].type).toBe('tableCell'); + expect(dataRow.content[1].content[0].type).toBe('paragraph'); + expect(dataRow.content[1].content[0].content[0].text).toBe('val'); + }); + + it('should wrap table cell content in paragraph nodes for multi-row table inside an expand', () => { + const markdown = `~~~expand title="Configuration" +| Setting | Default | Description | +|---------|---------|--------------| +| timeout | 30s | Max wait | +| retries | 3 | Retry count | +~~~`; + + const result = parseMarkdown(markdown); + + const expand = result.content[0]; + expect(expand.type).toBe('expand'); + + const table = expand.content.find((node: any) => node.type === 'table'); + expect(table).toBeDefined(); + expect(table.content).toHaveLength(3); // header + 2 data rows + + // Verify all header cells have paragraph wrapping + const headerRow = table.content[0]; + expect(headerRow.content).toHaveLength(3); + for (const headerCell of headerRow.content) { + expect(headerCell.type).toBe('tableHeader'); + expect(headerCell.content[0].type).toBe('paragraph'); + } + expect(headerRow.content[0].content[0].content[0].text).toBe('Setting'); + expect(headerRow.content[1].content[0].content[0].text).toBe('Default'); + expect(headerRow.content[2].content[0].content[0].text).toBe('Description'); + + // Verify all data cells have paragraph wrapping + const dataRow1 = table.content[1]; + for (const cell of dataRow1.content) { + expect(cell.type).toBe('tableCell'); + expect(cell.content[0].type).toBe('paragraph'); + } + expect(dataRow1.content[0].content[0].content[0].text).toBe('timeout'); + expect(dataRow1.content[1].content[0].content[0].text).toBe('30s'); + expect(dataRow1.content[2].content[0].content[0].text).toBe('Max wait'); + + const dataRow2 = table.content[2]; + for (const cell of dataRow2.content) { + expect(cell.type).toBe('tableCell'); + expect(cell.content[0].type).toBe('paragraph'); + } + expect(dataRow2.content[0].content[0].content[0].text).toBe('retries'); + expect(dataRow2.content[1].content[0].content[0].text).toBe('3'); + expect(dataRow2.content[2].content[0].content[0].text).toBe('Retry count'); + }); + }); + describe('Edge Cases', () => { - it('should handle empty table cells', async () => { + it('should handle empty table cells', () => { const markdown = ` | Header 1 | Header 2 | |----------|----------| @@ -278,26 +517,26 @@ Installation completed successfully! | | Value | `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + expect(result.content[0].type).toBe('table'); const table = result.content[0]; - + // Should handle empty cells gracefully expect(table.content).toHaveLength(3); // header + 2 data rows }); - it('should handle panel with no title', async () => { + it('should handle panel with no title', () => { const markdown = ` ~~~panel type=info Panel content without a title. ~~~ `.trim(); - const result = await parser.markdownToAdf(markdown); - + const result = parseMarkdown(markdown); + expect(result.content[0].type).toBe('panel'); expect(result.content[0].attrs.panelType).toBe('info'); }); }); -}); \ No newline at end of file +}); diff --git a/src/__tests__/working-elements-validation.test.ts b/src/__tests__/working-elements-validation.test.ts index 732359e..2980a24 100644 --- a/src/__tests__/working-elements-validation.test.ts +++ b/src/__tests__/working-elements-validation.test.ts @@ -171,13 +171,15 @@ describe('Working Elements Validation Tests', () => { const headerRow = table.content[0]; expect(headerRow.type).toBe('tableRow'); expect(headerRow.content[0].type).toBe('tableHeader'); - expect(headerRow.content[0].content[0].text).toBe('Header 1'); - + expect(headerRow.content[0].content[0].type).toBe('paragraph'); + expect(headerRow.content[0].content[0].content[0].text).toBe('Header 1'); + // Check data rows const dataRow = table.content[1]; expect(dataRow.type).toBe('tableRow'); expect(dataRow.content[0].type).toBe('tableCell'); - expect(dataRow.content[0].content[0].text).toBe('Data 1'); + expect(dataRow.content[0].content[0].type).toBe('paragraph'); + expect(dataRow.content[0].content[0].content[0].text).toBe('Data 1'); }); }); diff --git a/src/parser/engines/MarkdownToAdfEngine.ts b/src/parser/engines/MarkdownToAdfEngine.ts index 2da4ccd..d0545e5 100644 --- a/src/parser/engines/MarkdownToAdfEngine.ts +++ b/src/parser/engines/MarkdownToAdfEngine.ts @@ -530,11 +530,11 @@ export class MarkdownToAdfEngine { private cleanupEmptyParagraphs(adf: ADFDocument): ADFDocument { const cleanupNode = (node: any): boolean => { // If this is a paragraph with only whitespace text nodes, remove it - if (node.type === 'paragraph' && node.content) { - const hasOnlyWhitespace = node.content.every((child: any) => + if (node.type === 'paragraph' && node.content && node.content.length > 0) { + const hasOnlyWhitespace = node.content.every((child: any) => child.type === 'text' && (!child.text || child.text.trim() === '') ); - + if (hasOnlyWhitespace) { return false; // Mark for removal } diff --git a/src/parser/markdown-to-adf/ASTBuilder.ts b/src/parser/markdown-to-adf/ASTBuilder.ts index d9e4f9f..e2889a3 100644 --- a/src/parser/markdown-to-adf/ASTBuilder.ts +++ b/src/parser/markdown-to-adf/ASTBuilder.ts @@ -316,7 +316,7 @@ export class ASTBuilder { const node: ADFNode = { type: isHeader ? 'tableHeader' : 'tableCell', - content + content: this.wrapCellContentInParagraph(content) }; const attrs: any = { ...customAttrs }; @@ -341,6 +341,22 @@ export class ASTBuilder { return node; } + private wrapCellContentInParagraph(content: ADFNode[]): ADFNode[] { + if (!content || content.length === 0) { + return [{ type: 'paragraph', content: [] }]; + } + const blockTypes = [ + 'paragraph', 'codeBlock', 'bulletList', 'orderedList', + 'blockquote', 'heading', 'mediaSingle', 'rule', 'panel', + 'mediaGroup', 'table', 'expand', 'nestedExpand' + ]; + const hasBlockContent = content.some(n => blockTypes.includes(n.type)); + if (hasBlockContent) { + return content; + } + return [{ type: 'paragraph', content }]; + } + private convertPanel(token: Token): ADFNode { const fenceToken = token as any; // FenceToken const panelType = fenceToken.attributes?.type || 'info'; @@ -1680,7 +1696,7 @@ export class ASTBuilder { private convertMdastTableCell(node: any, isHeader = false): ADFNode { const adfNode: ADFNode = { type: isHeader ? 'tableHeader' : 'tableCell', - content: this.convertMdastNodesToADF(node.children) + content: this.wrapCellContentInParagraph(this.convertMdastNodesToADF(node.children)) }; // Apply metadata if available @@ -2465,7 +2481,7 @@ export class ASTBuilder { const isHeader = !headerProcessed; const cellNodes: ADFNode[] = cells.map(cellContent => ({ type: isHeader ? 'tableHeader' : 'tableCell', - content: this.parseInlineContentWithSocialElements(cellContent) + content: this.wrapCellContentInParagraph(this.parseInlineContentWithSocialElements(cellContent)) })); tableRows.push({ diff --git a/tests/unit/parser/ASTBuilder.block-parsing.test.ts b/tests/unit/parser/ASTBuilder.block-parsing.test.ts index 97f85ce..a3c8f51 100644 --- a/tests/unit/parser/ASTBuilder.block-parsing.test.ts +++ b/tests/unit/parser/ASTBuilder.block-parsing.test.ts @@ -43,15 +43,15 @@ describe('ASTBuilder Block Content Parsing', () => { const firstDataRow = table.content[1]; expect(firstDataRow.type).toBe('tableRow'); - // Check Owner cell (index 1) contains mention + // Check Owner cell (index 1) contains mention - cell content is wrapped in paragraph per ADF spec const ownerCell = firstDataRow.content[1]; - const mentionNode = ownerCell.content.find((node: any) => node.type === 'mention'); + const mentionNode = ownerCell.content[0].content.find((node: any) => node.type === 'mention'); expect(mentionNode).toBeDefined(); expect(mentionNode.attrs.id).toBe('builder'); - + // Check Status cell (index 2) contains status const statusCell = firstDataRow.content[2]; - const statusNode = statusCell.content.find((node: any) => node.type === 'status'); + const statusNode = statusCell.content[0].content.find((node: any) => node.type === 'status'); expect(statusNode).toBeDefined(); expect(statusNode.attrs.text).toBe('active'); }); @@ -89,9 +89,10 @@ Pipeline runs daily at midnight. const statusCell = firstDataRow.content[3]; const dateCell = firstDataRow.content[4]; - expect(mentionCell.content.some((n: any) => n.type === 'mention')).toBeTruthy(); - expect(statusCell.content.some((n: any) => n.type === 'status')).toBeTruthy(); - expect(dateCell.content.some((n: any) => n.type === 'date')).toBeTruthy(); + // Cell content is wrapped in paragraph per ADF spec + expect(mentionCell.content[0].content.some((n: any) => n.type === 'mention')).toBeTruthy(); + expect(statusCell.content[0].content.some((n: any) => n.type === 'status')).toBeTruthy(); + expect(dateCell.content[0].content.some((n: any) => n.type === 'date')).toBeTruthy(); }); }); diff --git a/tests/unit/parser/ASTBuilder.nested-elements.test.ts b/tests/unit/parser/ASTBuilder.nested-elements.test.ts index c2abe68..ca76604 100644 --- a/tests/unit/parser/ASTBuilder.nested-elements.test.ts +++ b/tests/unit/parser/ASTBuilder.nested-elements.test.ts @@ -43,10 +43,10 @@ describe('ASTBuilder Nested Elements Parsing', () => { const firstRowSecondCell = dataRow1?.content?.[1]; expect(firstRowSecondCell?.type).toBe('tableCell'); - // This should contain a mention node, but currently fails due to convertInlineContent issue - const cellContent = firstRowSecondCell?.content || []; + // Cell content is wrapped in a paragraph node per ADF spec + const cellContent = firstRowSecondCell?.content?.[0]?.content || []; const mentionNode = cellContent.find((node: any) => node.type === 'mention'); - + // FAILING TEST - demonstrates the bug if (mentionNode) { expect(mentionNode.attrs?.id).toBe('project.lead'); @@ -72,9 +72,10 @@ describe('ASTBuilder Nested Elements Parsing', () => { const dataRow1 = table.content?.[1]; const statusCell = dataRow1?.content?.[1]; - const cellContent = statusCell?.content || []; + // Cell content is wrapped in a paragraph node per ADF spec + const cellContent = statusCell?.content?.[0]?.content || []; const emojiNode = cellContent.find((node: any) => node.type === 'emoji'); - + // FAILING TEST - demonstrates the bug if (emojiNode) { expect(emojiNode.attrs?.shortName).toBe(':white_check_mark:'); @@ -98,9 +99,10 @@ describe('ASTBuilder Nested Elements Parsing', () => { const dataRow1 = table.content?.[1]; const statusCell = dataRow1?.content?.[1]; - const cellContent = statusCell?.content || []; + // Cell content is wrapped in a paragraph node per ADF spec + const cellContent = statusCell?.content?.[0]?.content || []; const statusNode = cellContent.find((node: any) => node.type === 'status'); - + // FAILING TEST - demonstrates the bug if (statusNode) { expect(statusNode.attrs?.text).toBe('active'); @@ -125,9 +127,10 @@ describe('ASTBuilder Nested Elements Parsing', () => { const dataRow1 = table.content?.[1]; const dateCell = dataRow1?.content?.[1]; - const cellContent = dateCell?.content || []; + // Cell content is wrapped in a paragraph node per ADF spec + const cellContent = dateCell?.content?.[0]?.content || []; const dateNode = cellContent.find((node: any) => node.type === 'date'); - + // FAILING TEST - demonstrates the bug if (dateNode) { // Date should be converted to Unix timestamp @@ -151,13 +154,13 @@ describe('ASTBuilder Nested Elements Parsing', () => { const table = adf.content[0]; const dataRow = table.content?.[1]; - // Check owner cell + // Check owner cell - content is wrapped in a paragraph node per ADF spec const ownerCell = dataRow?.content?.[1]; - const ownerContent = ownerCell?.content || []; - + const ownerContent = ownerCell?.content?.[0]?.content || []; + const mentionNode = ownerContent.find((node: any) => node.type === 'mention'); const emojiNode = ownerContent.find((node: any) => node.type === 'emoji'); - + // These tests may fail due to the convertInlineContent issue if (!mentionNode || !emojiNode) { console.warn('KNOWN ISSUE: Mixed social elements in table cells not parsing correctly'); @@ -269,21 +272,22 @@ describe('ASTBuilder Nested Elements Parsing', () => { const contactCell = dataRow1?.content?.[3]; // Check for social elements in table cells within expand - const personContent = personCell?.content || []; - const statusContent = statusCell?.content || []; - const contactContent = contactCell?.content || []; - + // Cell content is wrapped in a paragraph node per ADF spec + const personContent = personCell?.content?.[0]?.content || []; + const statusContent = statusCell?.content?.[0]?.content || []; + const contactContent = contactCell?.content?.[0]?.content || []; + const mentionNode = personContent.find((node: any) => node.type === 'mention'); const emojiNode = personContent.find((node: any) => node.type === 'emoji'); const statusNode = statusContent.find((node: any) => node.type === 'status'); const dateNode = contactContent.find((node: any) => node.type === 'date'); - + // CRITICAL FAILING TEST - this is the exact scenario the user reported if (!mentionNode || !emojiNode || !statusNode || !dateNode) { console.error('CRITICAL ISSUE: Social elements in tables within expand blocks not parsing correctly'); console.error('This matches the user-reported issue where @build.engineer appears as plain text'); } - + // Fallback checks to document current broken behavior if (!mentionNode) { const textNode = personContent.find((node: any) => node.type === 'text'); @@ -306,7 +310,8 @@ describe('ASTBuilder Nested Elements Parsing', () => { const membersCell = dataRow1?.content?.[1]; // FAILING TEST - complex nested parsing - const cellContent = membersCell?.content || []; + // Cell content is wrapped in a paragraph node per ADF spec + const cellContent = membersCell?.content?.[0]?.content || []; console.warn('Complex nested elements in table cells may not parse correctly'); }); }); @@ -395,13 +400,14 @@ Due date: {date:2024-07-15} const ownerCell = dataRow?.content?.[0]; const statusCell = dataRow?.content?.[1]; - const ownerContent = ownerCell?.content || []; - const statusContent = statusCell?.content || []; - + // Cell content is wrapped in a paragraph node per ADF spec + const ownerContent = ownerCell?.content?.[0]?.content || []; + const statusContent = statusCell?.content?.[0]?.content || []; + const mentionNode = ownerContent.find((node: any) => node.type === 'mention'); const statusNode = statusContent.find((node: any) => node.type === 'status'); const emojiNode = statusContent.find((node: any) => node.type === 'emoji'); - + // After fix, these should all pass expect(mentionNode?.attrs?.id).toBe('owner.name'); expect(statusNode?.attrs?.text).toBe('active'); @@ -453,14 +459,15 @@ Due date: {date:2024-07-15} const statusCell = dataRow?.content?.[3]; const dateCell = dataRow?.content?.[4]; - const ownerContent = ownerCell?.content || []; - const statusContent = statusCell?.content || []; - const dateContent = dateCell?.content || []; - + // Cell content is wrapped in a paragraph node per ADF spec + const ownerContent = ownerCell?.content?.[0]?.content || []; + const statusContent = statusCell?.content?.[0]?.content || []; + const dateContent = dateCell?.content?.[0]?.content || []; + const mentionNode = ownerContent.find((node: any) => node.type === 'mention'); const statusNode = statusContent.find((node: any) => node.type === 'status'); const dateNode = dateContent.find((node: any) => node.type === 'date'); - + // After fix, these should work - resolving the user's exact issue expect(mentionNode?.attrs?.id).toBe('build.engineer'); expect(mentionNode?.attrs?.text).toBe('@build.engineer');