Spaces:
Sleeping
Sleeping
| import { describe, it, expect } from 'vitest'; | |
| import { convertMarkdownToRequests } from './markdownToDocs.js'; | |
| import { docsJsonToMarkdown } from './docsToMarkdown.js'; | |
| // ============================================================ | |
| // Markdown -> Google Docs Requests | |
| // ============================================================ | |
| describe('Markdown to Docs Conversion', () => { | |
| describe('Basic Text Formatting', () => { | |
| it('should convert bold text', () => { | |
| const requests = convertMarkdownToRequests('**bold text**', 1); | |
| const insertReq = requests.find((r) => r.insertText); | |
| expect(insertReq).toBeDefined(); | |
| expect(insertReq!.insertText!.text).toBe('bold text'); | |
| const styleReq = requests.find((r) => r.updateTextStyle); | |
| expect(styleReq).toBeDefined(); | |
| expect(styleReq!.updateTextStyle!.textStyle!.bold).toBe(true); | |
| }); | |
| it('should convert italic text', () => { | |
| const requests = convertMarkdownToRequests('*italic text*', 1); | |
| const styleReq = requests.find((r) => r.updateTextStyle); | |
| expect(styleReq).toBeDefined(); | |
| expect(styleReq!.updateTextStyle!.textStyle!.italic).toBe(true); | |
| }); | |
| it('should convert strikethrough text', () => { | |
| const requests = convertMarkdownToRequests('~~strikethrough text~~', 1); | |
| const styleReq = requests.find((r) => r.updateTextStyle); | |
| expect(styleReq).toBeDefined(); | |
| expect(styleReq!.updateTextStyle!.textStyle!.strikethrough).toBe(true); | |
| }); | |
| it('should convert nested bold and italic', () => { | |
| const requests = convertMarkdownToRequests('***bold italic***', 1); | |
| const styleReq = requests.find((r) => r.updateTextStyle); | |
| expect(styleReq).toBeDefined(); | |
| expect(styleReq!.updateTextStyle!.textStyle!.bold).toBe(true); | |
| expect(styleReq!.updateTextStyle!.textStyle!.italic).toBe(true); | |
| }); | |
| it('should style inline code as monospace', () => { | |
| const requests = convertMarkdownToRequests('Use `inline_code` here', 1); | |
| const styleReqs = requests.filter((r) => r.updateTextStyle); | |
| const codeStyleReq = styleReqs.find( | |
| (r) => r.updateTextStyle!.textStyle!.weightedFontFamily?.fontFamily === 'Roboto Mono' | |
| ); | |
| expect(codeStyleReq).toBeDefined(); | |
| }); | |
| }); | |
| describe('Links', () => { | |
| it('should convert basic links', () => { | |
| const requests = convertMarkdownToRequests('[link text](https://example.com)', 1); | |
| const insertReq = requests.find((r) => r.insertText); | |
| expect(insertReq).toBeDefined(); | |
| expect(insertReq!.insertText!.text).toBe('link text'); | |
| const styleReq = requests.find((r) => r.updateTextStyle); | |
| expect(styleReq).toBeDefined(); | |
| expect(styleReq!.updateTextStyle!.textStyle!.link!.url).toBe('https://example.com'); | |
| }); | |
| }); | |
| describe('Headings', () => { | |
| it('should convert H1', () => { | |
| const requests = convertMarkdownToRequests('# Heading 1', 1); | |
| const insertReq = requests.find((r) => r.insertText && r.insertText.text === 'Heading 1'); | |
| expect(insertReq).toBeDefined(); | |
| const paraReq = requests.find((r) => r.updateParagraphStyle); | |
| expect(paraReq).toBeDefined(); | |
| expect(paraReq!.updateParagraphStyle!.paragraphStyle!.namedStyleType).toBe('HEADING_1'); | |
| }); | |
| it('should convert H2', () => { | |
| const requests = convertMarkdownToRequests('## Heading 2', 1); | |
| const paraReq = requests.find((r) => r.updateParagraphStyle); | |
| expect(paraReq).toBeDefined(); | |
| expect(paraReq!.updateParagraphStyle!.paragraphStyle!.namedStyleType).toBe('HEADING_2'); | |
| }); | |
| it('should convert H3', () => { | |
| const requests = convertMarkdownToRequests('### Heading 3', 1); | |
| const paraReq = requests.find((r) => r.updateParagraphStyle); | |
| expect(paraReq).toBeDefined(); | |
| expect(paraReq!.updateParagraphStyle!.paragraphStyle!.namedStyleType).toBe('HEADING_3'); | |
| }); | |
| }); | |
| describe('firstHeadingAsTitle option', () => { | |
| it('should style the first H1 as TITLE when enabled', () => { | |
| const requests = convertMarkdownToRequests( | |
| '# My Document Title\n\nSome body text.', | |
| 1, | |
| undefined, | |
| { | |
| firstHeadingAsTitle: true, | |
| } | |
| ); | |
| const paraReqs = requests.filter((r) => r.updateParagraphStyle); | |
| const titleReq = paraReqs.find( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'TITLE' | |
| ); | |
| expect(titleReq).toBeDefined(); | |
| // Should NOT have a HEADING_1 | |
| const h1Req = paraReqs.find( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'HEADING_1' | |
| ); | |
| expect(h1Req).toBeUndefined(); | |
| }); | |
| it('should only convert the first H1 to TITLE, not subsequent H1s', () => { | |
| const markdown = '# Title\n\n# Second H1\n\nSome text.'; | |
| const requests = convertMarkdownToRequests(markdown, 1, undefined, { | |
| firstHeadingAsTitle: true, | |
| }); | |
| const paraReqs = requests.filter((r) => r.updateParagraphStyle); | |
| const titleReqs = paraReqs.filter( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'TITLE' | |
| ); | |
| const h1Reqs = paraReqs.filter( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'HEADING_1' | |
| ); | |
| expect(titleReqs).toHaveLength(1); | |
| expect(h1Reqs).toHaveLength(1); | |
| }); | |
| it('should leave H1 as HEADING_1 when option is disabled (default)', () => { | |
| const requests = convertMarkdownToRequests('# Heading 1', 1); | |
| const paraReq = requests.find((r) => r.updateParagraphStyle); | |
| expect(paraReq).toBeDefined(); | |
| expect(paraReq!.updateParagraphStyle!.paragraphStyle!.namedStyleType).toBe('HEADING_1'); | |
| }); | |
| it('should not affect H2+ headings when enabled', () => { | |
| const markdown = '## Section\n\n### Subsection'; | |
| const requests = convertMarkdownToRequests(markdown, 1, undefined, { | |
| firstHeadingAsTitle: true, | |
| }); | |
| const paraReqs = requests.filter((r) => r.updateParagraphStyle); | |
| const titleReqs = paraReqs.filter( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'TITLE' | |
| ); | |
| expect(titleReqs).toHaveLength(0); | |
| const h2 = paraReqs.find( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'HEADING_2' | |
| ); | |
| const h3 = paraReqs.find( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'HEADING_3' | |
| ); | |
| expect(h2).toBeDefined(); | |
| expect(h3).toBeDefined(); | |
| }); | |
| it('should handle a full document with title, headings, and lists', () => { | |
| const markdown = [ | |
| '# Project Plan', | |
| '', | |
| '## Overview', | |
| '', | |
| 'This is the overview.', | |
| '', | |
| '## Tasks', | |
| '', | |
| '- Task 1', | |
| '- Task 2', | |
| ].join('\n'); | |
| const requests = convertMarkdownToRequests(markdown, 1, undefined, { | |
| firstHeadingAsTitle: true, | |
| }); | |
| const paraReqs = requests.filter((r) => r.updateParagraphStyle); | |
| const titleReqs = paraReqs.filter( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'TITLE' | |
| ); | |
| const h2Reqs = paraReqs.filter( | |
| (r) => r.updateParagraphStyle!.paragraphStyle!.namedStyleType === 'HEADING_2' | |
| ); | |
| expect(titleReqs).toHaveLength(1); | |
| expect(h2Reqs).toHaveLength(2); | |
| }); | |
| }); | |
| describe('Lists', () => { | |
| it('should convert bullet lists', () => { | |
| const requests = convertMarkdownToRequests('- Item 1\n- Item 2\n- Item 3', 1); | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| expect(bulletReqs).toHaveLength(1); | |
| expect(bulletReqs[0].createParagraphBullets!.bulletPreset).toBe('BULLET_DISC_CIRCLE_SQUARE'); | |
| }); | |
| it('should convert numbered lists', () => { | |
| const requests = convertMarkdownToRequests('1. Item 1\n2. Item 2\n3. Item 3', 1); | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| expect(bulletReqs).toHaveLength(1); | |
| expect(bulletReqs[0].createParagraphBullets!.bulletPreset).toBe( | |
| 'NUMBERED_DECIMAL_ALPHA_ROMAN' | |
| ); | |
| }); | |
| it('should preserve nested list levels with leading tabs', () => { | |
| const requests = convertMarkdownToRequests('- Parent\n - Child', 1); | |
| const insertReqs = requests.filter((r) => r.insertText); | |
| expect(insertReqs.some((r) => r.insertText!.text!.includes('Parent'))).toBe(true); | |
| expect(insertReqs.some((r) => r.insertText!.text === '\t')).toBe(true); | |
| expect(insertReqs.some((r) => r.insertText!.text!.includes('Child'))).toBe(true); | |
| }); | |
| it('should insert multiple tabs for deeply nested lists (3 levels)', () => { | |
| const markdown = '- Level 0\n - Level 1\n - Level 2'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const insertReqs = requests.filter((r) => r.insertText); | |
| // Level 0 has no tab, Level 1 has 1 tab, Level 2 has 2 tabs | |
| expect(insertReqs.some((r) => r.insertText!.text === '\t\t')).toBe(true); | |
| expect(insertReqs.some((r) => r.insertText!.text!.includes('Level 2'))).toBe(true); | |
| }); | |
| it('should use ordered preset for nested ordered list inside bullets', () => { | |
| const markdown = '- Bullet parent\n 1. Ordered child 1\n 2. Ordered child 2'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| const presets = bulletReqs.map((r) => r.createParagraphBullets!.bulletPreset); | |
| expect(presets).toContain('BULLET_DISC_CIRCLE_SQUARE'); | |
| expect(presets).toContain('NUMBERED_DECIMAL_ALPHA_ROMAN'); | |
| }); | |
| it('should use bullet preset for nested bullets inside ordered list', () => { | |
| const markdown = '1. Ordered parent\n - Bullet child 1\n - Bullet child 2'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| const presets = bulletReqs.map((r) => r.createParagraphBullets!.bulletPreset); | |
| expect(presets).toContain('NUMBERED_DECIMAL_ALPHA_ROMAN'); | |
| expect(presets).toContain('BULLET_DISC_CIRCLE_SQUARE'); | |
| }); | |
| it('should produce separate bullet requests for mixed nested list types', () => { | |
| const markdown = '- Parent\n 1. Child\n- Parent 2'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| // Bullet and ordered are different presets so they cannot merge | |
| expect(bulletReqs.length).toBeGreaterThanOrEqual(2); | |
| }); | |
| it('should merge sibling items of the same type even around nested sub-lists', () => { | |
| // Both "Parent 1" and "Parent 2" are BULLET_DISC_CIRCLE_SQUARE at level 0. | |
| // The ordered sub-list between them is a different preset. | |
| const markdown = '- Parent 1\n 1. Ordered child\n- Parent 2'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const allText = requests | |
| .filter((r) => r.insertText) | |
| .map((r) => r.insertText!.text) | |
| .join(''); | |
| expect(allText).toContain('Parent 1'); | |
| expect(allText).toContain('Ordered child'); | |
| expect(allText).toContain('Parent 2'); | |
| }); | |
| it('should convert markdown task lists to checkbox bullets', () => { | |
| const requests = convertMarkdownToRequests('- [x] done\n- [ ] todo', 1); | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| expect(bulletReqs).toHaveLength(1); | |
| expect(bulletReqs[0].createParagraphBullets!.bulletPreset).toBe('BULLET_CHECKBOX'); | |
| const allInsertedText = requests | |
| .filter((r) => r.insertText) | |
| .map((r) => r.insertText!.text) | |
| .join(''); | |
| expect(allInsertedText).not.toContain('[x]'); | |
| expect(allInsertedText).not.toContain('[ ]'); | |
| }); | |
| it('should not let list bullet ranges bleed into following headings', () => { | |
| const requests = convertMarkdownToRequests('- Parent\n 1. Child\n\n## Next Heading', 1); | |
| const headingReq = requests.find( | |
| (r) => r.updateParagraphStyle?.paragraphStyle?.namedStyleType === 'HEADING_2' | |
| ); | |
| expect(headingReq).toBeDefined(); | |
| const headingStart = headingReq!.updateParagraphStyle!.range!.startIndex!; | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| const overlappingBullet = bulletReqs.find((r) => { | |
| const { startIndex, endIndex } = r.createParagraphBullets!.range!; | |
| return headingStart >= startIndex! && headingStart < endIndex!; | |
| }); | |
| expect(overlappingBullet).toBeUndefined(); | |
| }); | |
| it('should not merge separate bullet lists with content between them', () => { | |
| const markdown = [ | |
| '**Part 1: The Question**', | |
| '- Item A', | |
| '- Item B', | |
| '', | |
| '**Part 2: The Results**', | |
| '- Item C', | |
| '- Item D', | |
| ].join('\n'); | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| // Should produce two separate bullet ranges, not one merged range | |
| expect(bulletReqs).toHaveLength(2); | |
| // The paragraph "Part 2: The Results" must not fall inside any bullet range | |
| const insertReqs = requests.filter((r) => r.insertText); | |
| let part2Index: number | undefined; | |
| for (const r of insertReqs) { | |
| if (r.insertText!.text!.includes('Part 2')) { | |
| part2Index = r.insertText!.location!.index!; | |
| break; | |
| } | |
| } | |
| expect(part2Index).toBeDefined(); | |
| for (const b of bulletReqs) { | |
| const { startIndex, endIndex } = b.createParagraphBullets!.range!; | |
| const inside = part2Index! >= startIndex! && part2Index! < endIndex!; | |
| expect(inside).toBe(false); | |
| } | |
| }); | |
| it('should keep adjacent items in the same list merged', () => { | |
| const requests = convertMarkdownToRequests('- A\n- B\n- C', 1); | |
| const bulletReqs = requests.filter((r) => r.createParagraphBullets); | |
| expect(bulletReqs).toHaveLength(1); | |
| }); | |
| }); | |
| describe('Code Blocks', () => { | |
| it('should insert a 1x1 table for fenced code blocks', () => { | |
| const requests = convertMarkdownToRequests('```js\nconst x = 1;\nconsole.log(x);\n```', 1); | |
| // Should have an insertTable request | |
| const tableReqs = requests.filter((r) => r.insertTable); | |
| expect(tableReqs).toHaveLength(1); | |
| expect(tableReqs[0].insertTable!.rows).toBe(1); | |
| expect(tableReqs[0].insertTable!.columns).toBe(1); | |
| }); | |
| it('should insert code text into the table cell', () => { | |
| const requests = convertMarkdownToRequests('```\nhello world\n```', 1); | |
| const insertReqs = requests.filter((r) => r.insertText); | |
| expect(insertReqs.some((r) => r.insertText!.text!.includes('hello world'))).toBe(true); | |
| }); | |
| it('should style code block text as monospace', () => { | |
| const requests = convertMarkdownToRequests('```\nconst x = 1;\nconsole.log(x);\n```', 1); | |
| const styleReqs = requests.filter((r) => r.updateTextStyle); | |
| const monospaceReqs = styleReqs.filter( | |
| (r) => r.updateTextStyle!.textStyle!.weightedFontFamily?.fontFamily === 'Roboto Mono' | |
| ); | |
| expect(monospaceReqs).toHaveLength(1); | |
| }); | |
| it('should style the table cell with background color', () => { | |
| const requests = convertMarkdownToRequests('```\ncode\n```', 1); | |
| const cellStyleReqs = requests.filter((r) => r.updateTableCellStyle); | |
| expect(cellStyleReqs).toHaveLength(1); | |
| const cellStyle = cellStyleReqs[0].updateTableCellStyle!.tableCellStyle!; | |
| expect(cellStyle.backgroundColor).toBeDefined(); | |
| expect(cellStyle.paddingTop).toBeDefined(); | |
| expect(cellStyle.paddingBottom).toBeDefined(); | |
| expect(cellStyle.paddingLeft).toBeDefined(); | |
| expect(cellStyle.paddingRight).toBeDefined(); | |
| }); | |
| it('should reference the actual table start (insertTable index + 1) in updateTableCellStyle', () => { | |
| // insertTable auto-inserts a preceding newline at T, so the table element | |
| // starts at T+1. The updateTableCellStyle must reference T+1, not T. | |
| const requests = convertMarkdownToRequests('```\ncode\n```', 1); | |
| const tableReq = requests.find((r) => r.insertTable); | |
| const cellStyleReq = requests.find((r) => r.updateTableCellStyle); | |
| expect(tableReq).toBeDefined(); | |
| expect(cellStyleReq).toBeDefined(); | |
| const insertTableIndex = tableReq!.insertTable!.location!.index!; | |
| const tableStartLocationIndex = | |
| cellStyleReq!.updateTableCellStyle!.tableRange!.tableCellLocation!.tableStartLocation! | |
| .index!; | |
| // The actual table start is insertTable target + 1 (preceding newline shifts it) | |
| expect(tableStartLocationIndex).toBe(insertTableIndex + 1); | |
| }); | |
| it('should insert code text at correct offset from table start', () => { | |
| const requests = convertMarkdownToRequests('```\nhello\n```', 1); | |
| const tableReq = requests.find((r) => r.insertTable); | |
| const codeInsertReq = requests.find((r) => r.insertText && r.insertText.text === 'hello'); | |
| expect(tableReq).toBeDefined(); | |
| expect(codeInsertReq).toBeDefined(); | |
| const tableIndex = tableReq!.insertTable!.location!.index!; | |
| const textIndex = codeInsertReq!.insertText!.location!.index!; | |
| // Cell content should be at table start + 4 (CELL_CONTENT_OFFSET) | |
| expect(textIndex).toBe(tableIndex + 4); | |
| }); | |
| it('should handle multi-line code blocks', () => { | |
| const requests = convertMarkdownToRequests('```\nline1\nline2\nline3\n```', 1); | |
| const insertReqs = requests.filter((r) => r.insertText); | |
| const codeContent = insertReqs.find((r) => r.insertText!.text === 'line1\nline2\nline3'); | |
| expect(codeContent).toBeDefined(); | |
| }); | |
| it('should handle empty code blocks', () => { | |
| const requests = convertMarkdownToRequests('```\n```', 1); | |
| const tableReqs = requests.filter((r) => r.insertTable); | |
| expect(tableReqs).toHaveLength(1); | |
| // No code text insertion (empty block) | |
| const codeInsertReqs = requests.filter((r) => r.insertText && r.insertText.text !== '\n'); | |
| expect(codeInsertReqs).toHaveLength(0); | |
| }); | |
| it('should handle multiple code blocks in sequence', () => { | |
| const markdown = '```\ncode1\n```\n\n```\ncode2\n```'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const tableReqs = requests.filter((r) => r.insertTable); | |
| expect(tableReqs).toHaveLength(2); | |
| const cellStyleReqs = requests.filter((r) => r.updateTableCellStyle); | |
| expect(cellStyleReqs).toHaveLength(2); | |
| }); | |
| it('should include tabId in table, text, and cell style requests when provided', () => { | |
| const requests = convertMarkdownToRequests('```\ncode\n```', 1, 'tab-code'); | |
| const tableReq = requests.find((r) => r.insertTable); | |
| expect(tableReq!.insertTable!.location!.tabId).toBe('tab-code'); | |
| const codeInsertReq = requests.find((r) => r.insertText && r.insertText.text === 'code'); | |
| expect(codeInsertReq!.insertText!.location!.tabId).toBe('tab-code'); | |
| const cellStyleReq = requests.find((r) => r.updateTableCellStyle); | |
| expect( | |
| cellStyleReq!.updateTableCellStyle!.tableRange!.tableCellLocation!.tableStartLocation!.tabId | |
| ).toBe('tab-code'); | |
| }); | |
| it('should not affect inline code styling', () => { | |
| const requests = convertMarkdownToRequests('Use `inline_code` here', 1); | |
| // Inline code should NOT create a table | |
| const tableReqs = requests.filter((r) => r.insertTable); | |
| expect(tableReqs).toHaveLength(0); | |
| // Inline code should still use text styling (monospace + green + background) | |
| const styleReqs = requests.filter((r) => r.updateTextStyle); | |
| const codeStyleReq = styleReqs.find( | |
| (r) => r.updateTextStyle!.textStyle!.weightedFontFamily?.fontFamily === 'Roboto Mono' | |
| ); | |
| expect(codeStyleReq).toBeDefined(); | |
| }); | |
| it('should correctly track indices after a code block for following content', () => { | |
| const markdown = '```\ncode\n```\n\nFollowing text.'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| // The following text should have valid insert locations | |
| const followingInsert = requests.find( | |
| (r) => r.insertText && r.insertText.text!.includes('Following text') | |
| ); | |
| expect(followingInsert).toBeDefined(); | |
| expect(followingInsert!.insertText!.location!.index).toBeGreaterThan(1); | |
| }); | |
| }); | |
| describe('Mixed Content', () => { | |
| it('should convert document with multiple elements', () => { | |
| const markdown = `# Title | |
| This is **bold** and *italic* text with a [link](https://example.com). | |
| - List item 1 | |
| - List item 2 | |
| ## Heading 2 | |
| More content.`; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| expect(requests.some((r) => r.insertText)).toBe(true); | |
| expect(requests.some((r) => r.updateTextStyle)).toBe(true); | |
| expect(requests.some((r) => r.updateParagraphStyle)).toBe(true); | |
| expect(requests.some((r) => r.createParagraphBullets)).toBe(true); | |
| expect( | |
| requests.find((r) => r.updateParagraphStyle?.paragraphStyle?.namedStyleType === 'HEADING_1') | |
| ).toBeDefined(); | |
| expect( | |
| requests.find((r) => r.updateParagraphStyle?.paragraphStyle?.namedStyleType === 'HEADING_2') | |
| ).toBeDefined(); | |
| }); | |
| }); | |
| describe('Index Tracking', () => { | |
| it('should use correct start index', () => { | |
| const requests = convertMarkdownToRequests('Test text', 100); | |
| const insertReq = requests.find((r) => r.insertText); | |
| expect(insertReq).toBeDefined(); | |
| expect(insertReq!.insertText!.location!.index).toBe(100); | |
| }); | |
| it('should track indices for sequential inserts', () => { | |
| const requests = convertMarkdownToRequests('First paragraph.\n\nSecond paragraph.', 1); | |
| const insertReqs = requests.filter((r) => r.insertText); | |
| expect(insertReqs.length).toBeGreaterThan(0); | |
| for (const req of insertReqs) { | |
| expect(req.insertText!.location).toBeDefined(); | |
| expect(typeof req.insertText!.location!.index).toBe('number'); | |
| } | |
| }); | |
| }); | |
| describe('Tab Support', () => { | |
| it('should include tabId in requests when provided', () => { | |
| const requests = convertMarkdownToRequests('**bold text**', 1, 'tab123'); | |
| const insertReq = requests.find((r) => r.insertText); | |
| expect(insertReq).toBeDefined(); | |
| expect(insertReq!.insertText!.location!.tabId).toBe('tab123'); | |
| const styleReq = requests.find((r) => r.updateTextStyle); | |
| expect(styleReq).toBeDefined(); | |
| expect(styleReq!.updateTextStyle!.range!.tabId).toBe('tab123'); | |
| }); | |
| }); | |
| describe('Paragraph Spacing', () => { | |
| it('should apply spaceBelow to normal text paragraphs', () => { | |
| const requests = convertMarkdownToRequests('First paragraph.\n\nSecond paragraph.', 1); | |
| const spacingReqs = requests.filter( | |
| (r) => | |
| r.updateParagraphStyle?.paragraphStyle?.spaceBelow && | |
| !r.updateParagraphStyle?.paragraphStyle?.namedStyleType && | |
| !r.updateParagraphStyle?.paragraphStyle?.borderBottom | |
| ); | |
| expect(spacingReqs).toHaveLength(2); | |
| for (const req of spacingReqs) { | |
| expect(req.updateParagraphStyle!.paragraphStyle!.spaceBelow!.magnitude).toBe(8); | |
| expect(req.updateParagraphStyle!.paragraphStyle!.spaceBelow!.unit).toBe('PT'); | |
| expect(req.updateParagraphStyle!.fields).toBe('spaceBelow'); | |
| } | |
| }); | |
| it('should only apply spaceBelow to the last item of a list, not every item', () => { | |
| const requests = convertMarkdownToRequests('- Item 1\n- Item 2\n- Item 3', 1); | |
| const spacingReqs = requests.filter( | |
| (r) => | |
| r.updateParagraphStyle?.paragraphStyle?.spaceBelow && | |
| !r.updateParagraphStyle?.paragraphStyle?.namedStyleType && | |
| !r.updateParagraphStyle?.paragraphStyle?.borderBottom | |
| ); | |
| // Only 1 spacing request: the trailing spacing on the last list item | |
| expect(spacingReqs).toHaveLength(1); | |
| }); | |
| it('should not apply spaceBelow to headings (they have named styles)', () => { | |
| const requests = convertMarkdownToRequests('# Heading\n\n## Subheading', 1); | |
| const spacingReqs = requests.filter( | |
| (r) => | |
| r.updateParagraphStyle?.paragraphStyle?.spaceBelow && | |
| !r.updateParagraphStyle?.paragraphStyle?.namedStyleType && | |
| !r.updateParagraphStyle?.paragraphStyle?.borderBottom | |
| ); | |
| expect(spacingReqs).toHaveLength(0); | |
| }); | |
| it('should apply spaceBelow to normal paragraphs and last list items in mixed content', () => { | |
| const markdown = '# Title\n\nA paragraph.\n\n- List item\n\nAnother paragraph.'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const spacingReqs = requests.filter( | |
| (r) => | |
| r.updateParagraphStyle?.paragraphStyle?.spaceBelow && | |
| !r.updateParagraphStyle?.paragraphStyle?.namedStyleType && | |
| !r.updateParagraphStyle?.paragraphStyle?.borderBottom | |
| ); | |
| // "A paragraph." + "Another paragraph." + last list item trailing spacing = 3 | |
| expect(spacingReqs).toHaveLength(3); | |
| }); | |
| it('should include tabId in spacing requests when provided', () => { | |
| const requests = convertMarkdownToRequests('A paragraph.', 1, 'tab-xyz'); | |
| const spacingReqs = requests.filter( | |
| (r) => | |
| r.updateParagraphStyle?.paragraphStyle?.spaceBelow && | |
| !r.updateParagraphStyle?.paragraphStyle?.namedStyleType | |
| ); | |
| expect(spacingReqs).toHaveLength(1); | |
| expect(spacingReqs[0].updateParagraphStyle!.range!.tabId).toBe('tab-xyz'); | |
| }); | |
| }); | |
| describe('List Trailing Spacing', () => { | |
| // Helper to find spacing requests that target list items (not normal paragraphs or headings). | |
| // We identify them by checking they don't overlap with normalParagraph spacing ranges or | |
| // heading styles. Instead we just verify the total spaceBelow count vs paragraph-only count. | |
| function getListSpacingReqs(requests: ReturnType<typeof convertMarkdownToRequests>) { | |
| // All spaceBelow requests that are NOT heading styles and NOT border styles | |
| return requests.filter( | |
| (r) => | |
| r.updateParagraphStyle?.paragraphStyle?.spaceBelow && | |
| !r.updateParagraphStyle?.paragraphStyle?.namedStyleType && | |
| !r.updateParagraphStyle?.paragraphStyle?.borderBottom | |
| ); | |
| } | |
| it('should apply spaceBelow to the last item of a bullet list', () => { | |
| const requests = convertMarkdownToRequests('- Item 1\n- Item 2\n- Item 3', 1); | |
| const spacingReqs = getListSpacingReqs(requests); | |
| // 1 request for the last list item (no normal paragraphs here) | |
| expect(spacingReqs).toHaveLength(1); | |
| expect(spacingReqs[0].updateParagraphStyle!.paragraphStyle!.spaceBelow!.magnitude).toBe(8); | |
| }); | |
| it('should apply spaceBelow to the last item of an ordered list', () => { | |
| const requests = convertMarkdownToRequests('1. First\n2. Second\n3. Third', 1); | |
| const spacingReqs = getListSpacingReqs(requests); | |
| expect(spacingReqs).toHaveLength(1); | |
| expect(spacingReqs[0].updateParagraphStyle!.paragraphStyle!.spaceBelow!.magnitude).toBe(8); | |
| }); | |
| it('should apply spaceBelow after each separate list in the document', () => { | |
| const markdown = '- A\n- B\n\nSome text.\n\n1. One\n2. Two'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const spacingReqs = getListSpacingReqs(requests); | |
| // 2 list-trailing spacing + 1 normal paragraph spacing = 3 total | |
| expect(spacingReqs).toHaveLength(3); | |
| }); | |
| it('should create spacing between a list and the following paragraph', () => { | |
| const markdown = '- Item 1\n- Item 2\n\nFollowing paragraph.'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const spacingReqs = getListSpacingReqs(requests); | |
| // 1 for last list item + 1 for the following paragraph = 2 | |
| expect(spacingReqs).toHaveLength(2); | |
| }); | |
| it('should handle nested lists and apply spacing after the top-level list', () => { | |
| const markdown = '- Parent\n - Child 1\n - Child 2\n\nAfter the list.'; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| const spacingReqs = getListSpacingReqs(requests); | |
| // 1 for last item of the top-level list + 1 for the following paragraph = 2 | |
| expect(spacingReqs).toHaveLength(2); | |
| }); | |
| it('should include tabId in list spacing requests when provided', () => { | |
| const requests = convertMarkdownToRequests('- Item 1\n- Item 2', 1, 'tab-list'); | |
| const spacingReqs = getListSpacingReqs(requests); | |
| expect(spacingReqs).toHaveLength(1); | |
| expect(spacingReqs[0].updateParagraphStyle!.range!.tabId).toBe('tab-list'); | |
| }); | |
| }); | |
| describe('Edge Cases', () => { | |
| it('should handle empty markdown', () => { | |
| expect(convertMarkdownToRequests('', 1)).toHaveLength(0); | |
| }); | |
| it('should handle whitespace-only markdown', () => { | |
| expect(convertMarkdownToRequests(' \n\n ', 1)).toHaveLength(0); | |
| }); | |
| it('should handle plain text without formatting', () => { | |
| const requests = convertMarkdownToRequests('Just plain text', 1); | |
| const insertReq = requests.find((r) => r.insertText); | |
| expect(insertReq).toBeDefined(); | |
| expect(insertReq!.insertText!.text).toBe('Just plain text'); | |
| const styleReqs = requests.filter((r) => r.updateTextStyle); | |
| expect(styleReqs).toHaveLength(0); | |
| }); | |
| }); | |
| describe('Horizontal Rules', () => { | |
| it('should produce a border-bottom paragraph style for ---', () => { | |
| const requests = convertMarkdownToRequests('Above\n\n---\n\nBelow', 1); | |
| const hrReqs = requests.filter((r) => r.updateParagraphStyle?.paragraphStyle?.borderBottom); | |
| expect(hrReqs).toHaveLength(1); | |
| const border = hrReqs[0].updateParagraphStyle!.paragraphStyle!.borderBottom!; | |
| expect(border.dashStyle).toBe('SOLID'); | |
| expect(border.width!.magnitude).toBe(1); | |
| expect(border.width!.unit).toBe('PT'); | |
| }); | |
| it('should handle multiple horizontal rules', () => { | |
| const requests = convertMarkdownToRequests( | |
| '# Title\n\n---\n\n## S1\n\nText.\n\n---\n\n## S2', | |
| 1 | |
| ); | |
| const hrReqs = requests.filter((r) => r.updateParagraphStyle?.paragraphStyle?.borderBottom); | |
| expect(hrReqs).toHaveLength(2); | |
| }); | |
| it('should not drop surrounding content', () => { | |
| const requests = convertMarkdownToRequests('Above\n\n---\n\nBelow', 1); | |
| const allText = requests | |
| .filter((r) => r.insertText) | |
| .map((r) => r.insertText!.text) | |
| .join(''); | |
| expect(allText).toContain('Above'); | |
| expect(allText).toContain('Below'); | |
| }); | |
| it('should place the HR paragraph between surrounding content', () => { | |
| const requests = convertMarkdownToRequests('Above\n\n---\n\nBelow', 1); | |
| const hrReqs = requests.filter((r) => r.updateParagraphStyle?.paragraphStyle?.borderBottom); | |
| expect(hrReqs).toHaveLength(1); | |
| const hrStart = hrReqs[0].updateParagraphStyle!.range!.startIndex!; | |
| const hrEnd = hrReqs[0].updateParagraphStyle!.range!.endIndex!; | |
| const aboveInsert = requests.find( | |
| (r) => r.insertText && r.insertText.text!.includes('Above') | |
| ); | |
| const belowInsert = requests.find( | |
| (r) => r.insertText && r.insertText.text!.includes('Below') | |
| ); | |
| expect(aboveInsert!.insertText!.location!.index).toBeLessThan(hrStart); | |
| expect(belowInsert!.insertText!.location!.index).toBeGreaterThanOrEqual(hrEnd); | |
| }); | |
| it('should include tabId on HR border requests when provided', () => { | |
| const requests = convertMarkdownToRequests('---', 1, 'tab-abc'); | |
| const hrReqs = requests.filter((r) => r.updateParagraphStyle?.paragraphStyle?.borderBottom); | |
| expect(hrReqs.length).toBeGreaterThan(0); | |
| expect(hrReqs[0].updateParagraphStyle!.range!.tabId).toBe('tab-abc'); | |
| }); | |
| it('should work in a realistic document with headings, lists, and rules', () => { | |
| const markdown = `# Project Plan | |
| --- | |
| ## Goals | |
| - **Speed:** Ship faster | |
| - **Quality:** Fewer bugs | |
| ## Timeline | |
| 1. Planning | |
| 2. Execution | |
| 3. Review | |
| --- | |
| *Last updated: 2026*`; | |
| const requests = convertMarkdownToRequests(markdown, 1); | |
| // HRs | |
| const hrReqs = requests.filter((r) => r.updateParagraphStyle?.paragraphStyle?.borderBottom); | |
| expect(hrReqs).toHaveLength(2); | |
| // Headings | |
| const h1Reqs = requests.filter( | |
| (r) => r.updateParagraphStyle?.paragraphStyle?.namedStyleType === 'HEADING_1' | |
| ); | |
| const h2Reqs = requests.filter( | |
| (r) => r.updateParagraphStyle?.paragraphStyle?.namedStyleType === 'HEADING_2' | |
| ); | |
| expect(h1Reqs).toHaveLength(1); | |
| expect(h2Reqs).toHaveLength(2); | |
| // Bullet lists (merged into one range) | |
| const bulletReqs = requests.filter( | |
| (r) => r.createParagraphBullets?.bulletPreset === 'BULLET_DISC_CIRCLE_SQUARE' | |
| ); | |
| expect(bulletReqs).toHaveLength(1); | |
| // Numbered list (merged into one range) | |
| const numberedReqs = requests.filter( | |
| (r) => r.createParagraphBullets?.bulletPreset === 'NUMBERED_DECIMAL_ALPHA_ROMAN' | |
| ); | |
| expect(numberedReqs).toHaveLength(1); | |
| // Bold | |
| const boldReqs = requests.filter((r) => r.updateTextStyle?.textStyle?.bold === true); | |
| expect(boldReqs.length).toBeGreaterThanOrEqual(2); | |
| // Italic | |
| const italicReqs = requests.filter((r) => r.updateTextStyle?.textStyle?.italic === true); | |
| expect(italicReqs.length).toBeGreaterThanOrEqual(1); | |
| // All text present | |
| const allText = requests | |
| .filter((r) => r.insertText) | |
| .map((r) => r.insertText!.text) | |
| .join(''); | |
| expect(allText).toContain('Project Plan'); | |
| expect(allText).toContain('Ship faster'); | |
| expect(allText).toContain('Execution'); | |
| expect(allText).toContain('Last updated: 2026'); | |
| }); | |
| }); | |
| }); | |
| // ============================================================ | |
| // Google Docs JSON -> Markdown | |
| // ============================================================ | |
| describe('Docs to Markdown Conversion', () => { | |
| describe('Headings', () => { | |
| it('should convert HEADING_1 to # heading', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| paragraphStyle: { namedStyleType: 'HEADING_1' }, | |
| elements: [{ textRun: { content: 'Hello\n' } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| expect(docsJsonToMarkdown(doc)).toContain('# Hello'); | |
| }); | |
| it('should convert HEADING_2 through HEADING_6', () => { | |
| const doc = { | |
| body: { | |
| content: [2, 3, 4, 5, 6].map((level) => ({ | |
| paragraph: { | |
| paragraphStyle: { namedStyleType: `HEADING_${level}` }, | |
| elements: [{ textRun: { content: `H${level}\n` } }], | |
| }, | |
| })), | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('## H2'); | |
| expect(md).toContain('### H3'); | |
| expect(md).toContain('#### H4'); | |
| expect(md).toContain('##### H5'); | |
| expect(md).toContain('###### H6'); | |
| }); | |
| it('should convert TITLE to H1 and SUBTITLE to H2', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| paragraphStyle: { namedStyleType: 'TITLE' }, | |
| elements: [{ textRun: { content: 'My Title\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| paragraphStyle: { namedStyleType: 'SUBTITLE' }, | |
| elements: [{ textRun: { content: 'My Subtitle\n' } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('# My Title'); | |
| expect(md).toContain('## My Subtitle'); | |
| }); | |
| }); | |
| describe('Text Formatting', () => { | |
| it('should convert bold text', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [{ textRun: { content: 'bold', textStyle: { bold: true } } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| expect(docsJsonToMarkdown(doc)).toContain('**bold**'); | |
| }); | |
| it('should convert italic text', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [{ textRun: { content: 'italic', textStyle: { italic: true } } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| expect(docsJsonToMarkdown(doc)).toContain('*italic*'); | |
| }); | |
| it('should convert bold+italic text', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [ | |
| { | |
| textRun: { | |
| content: 'both', | |
| textStyle: { bold: true, italic: true }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| expect(docsJsonToMarkdown(doc)).toContain('***both***'); | |
| }); | |
| it('should convert strikethrough text', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [{ textRun: { content: 'struck', textStyle: { strikethrough: true } } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| expect(docsJsonToMarkdown(doc)).toContain('~~struck~~'); | |
| }); | |
| it('should convert links', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [ | |
| { | |
| textRun: { | |
| content: 'click here', | |
| textStyle: { link: { url: 'https://example.com' } }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| expect(docsJsonToMarkdown(doc)).toContain('[click here](https://example.com)'); | |
| }); | |
| it('should detect monospace font as code', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [ | |
| { textRun: { content: 'normal ' } }, | |
| { | |
| textRun: { | |
| content: 'code_here', | |
| textStyle: { weightedFontFamily: { fontFamily: 'Roboto Mono' } }, | |
| }, | |
| }, | |
| { textRun: { content: ' more\n' } }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| expect(docsJsonToMarkdown(doc)).toContain('`code_here`'); | |
| }); | |
| }); | |
| describe('Lists', () => { | |
| it('should convert bullet list items', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| bullet: { listId: 'list1', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'Item 1\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| bullet: { listId: 'list1', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'Item 2\n' } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| lists: { | |
| list1: { | |
| listProperties: { | |
| nestingLevels: [{ glyphSymbol: '\u25cf' }], | |
| }, | |
| }, | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('- Item 1'); | |
| expect(md).toContain('- Item 2'); | |
| }); | |
| it('should detect ordered lists via glyphType', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| bullet: { listId: 'olist', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'First\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| bullet: { listId: 'olist', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'Second\n' } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| lists: { | |
| olist: { | |
| listProperties: { | |
| nestingLevels: [{ glyphType: 'DECIMAL' }], | |
| }, | |
| }, | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('1. First'); | |
| expect(md).toContain('1. Second'); | |
| }); | |
| it('should render nested lists with indentation', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| bullet: { listId: 'nlist', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'Parent\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| bullet: { listId: 'nlist', nestingLevel: 1 }, | |
| elements: [{ textRun: { content: 'Child\n' } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| lists: { | |
| nlist: { | |
| listProperties: { | |
| nestingLevels: [{ glyphSymbol: '\u25cf' }, { glyphSymbol: '\u25cb' }], | |
| }, | |
| }, | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('- Parent'); | |
| expect(md).toContain(' - Child'); | |
| }); | |
| it('should render 3 levels of nested bullet indentation', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| bullet: { listId: 'deep', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'Level 0\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| bullet: { listId: 'deep', nestingLevel: 1 }, | |
| elements: [{ textRun: { content: 'Level 1\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| bullet: { listId: 'deep', nestingLevel: 2 }, | |
| elements: [{ textRun: { content: 'Level 2\n' } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| lists: { | |
| deep: { | |
| listProperties: { | |
| nestingLevels: [ | |
| { glyphSymbol: '\u25cf' }, | |
| { glyphSymbol: '\u25cb' }, | |
| { glyphSymbol: '\u25a0' }, | |
| ], | |
| }, | |
| }, | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('- Level 0'); | |
| expect(md).toContain(' - Level 1'); | |
| expect(md).toContain(' - Level 2'); | |
| }); | |
| it('should render ordered sub-list inside bullet list', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| bullet: { listId: 'mixed', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'Bullet parent\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| bullet: { listId: 'mixed', nestingLevel: 1 }, | |
| elements: [{ textRun: { content: 'Ordered child\n' } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| lists: { | |
| mixed: { | |
| listProperties: { | |
| nestingLevels: [{ glyphSymbol: '\u25cf' }, { glyphType: 'DECIMAL' }], | |
| }, | |
| }, | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('- Bullet parent'); | |
| expect(md).toContain(' 1. Ordered child'); | |
| }); | |
| it('should return to parent indentation level after nested items', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| paragraph: { | |
| bullet: { listId: 'bounce', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'First\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| bullet: { listId: 'bounce', nestingLevel: 1 }, | |
| elements: [{ textRun: { content: 'Nested\n' } }], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| bullet: { listId: 'bounce', nestingLevel: 0 }, | |
| elements: [{ textRun: { content: 'Back to top\n' } }], | |
| }, | |
| }, | |
| ], | |
| }, | |
| lists: { | |
| bounce: { | |
| listProperties: { | |
| nestingLevels: [{ glyphSymbol: '\u25cf' }, { glyphSymbol: '\u25cb' }], | |
| }, | |
| }, | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| const lines = md.split('\n').filter((l) => l.trim()); | |
| const firstLine = lines.find((l) => l.includes('First')); | |
| const nestedLine = lines.find((l) => l.includes('Nested')); | |
| const backLine = lines.find((l) => l.includes('Back to top')); | |
| expect(firstLine).toBe('- First'); | |
| expect(nestedLine).toBe(' - Nested'); | |
| expect(backLine).toBe('- Back to top'); | |
| }); | |
| }); | |
| describe('Code Block Tables', () => { | |
| it('should detect a 1x1 table with gray background as a code block', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| table: { | |
| tableRows: [ | |
| { | |
| tableCells: [ | |
| { | |
| tableCellStyle: { | |
| backgroundColor: { | |
| color: { rgbColor: { red: 0.937, green: 0.945, blue: 0.953 } }, | |
| }, | |
| }, | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [ | |
| { | |
| textRun: { | |
| content: 'const x = 1;\n', | |
| textStyle: { | |
| weightedFontFamily: { fontFamily: 'Roboto Mono' }, | |
| }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('```'); | |
| expect(md).toContain('const x = 1;'); | |
| // Should NOT be a markdown table | |
| expect(md).not.toContain('|'); | |
| }); | |
| it('should detect a 1x1 table with monospace font as a code block', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| table: { | |
| tableRows: [ | |
| { | |
| tableCells: [ | |
| { | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [ | |
| { | |
| textRun: { | |
| content: 'print("hello")\n', | |
| textStyle: { | |
| weightedFontFamily: { fontFamily: 'Courier New' }, | |
| }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('```'); | |
| expect(md).toContain('print("hello")'); | |
| }); | |
| it('should NOT detect a regular 2x2 table as a code block', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| table: { | |
| tableRows: [ | |
| { | |
| tableCells: [ | |
| { | |
| content: [{ paragraph: { elements: [{ textRun: { content: 'A\n' } }] } }], | |
| }, | |
| { | |
| content: [{ paragraph: { elements: [{ textRun: { content: 'B\n' } }] } }], | |
| }, | |
| ], | |
| }, | |
| { | |
| tableCells: [ | |
| { | |
| content: [{ paragraph: { elements: [{ textRun: { content: '1\n' } }] } }], | |
| }, | |
| { | |
| content: [{ paragraph: { elements: [{ textRun: { content: '2\n' } }] } }], | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| // Should be a markdown table, not a code block | |
| expect(md).toContain('|'); | |
| expect(md).not.toContain('```'); | |
| }); | |
| it('should handle multi-line content in a code block table', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| table: { | |
| tableRows: [ | |
| { | |
| tableCells: [ | |
| { | |
| tableCellStyle: { | |
| backgroundColor: { | |
| color: { rgbColor: { red: 0.937, green: 0.945, blue: 0.953 } }, | |
| }, | |
| }, | |
| content: [ | |
| { | |
| paragraph: { | |
| elements: [ | |
| { | |
| textRun: { | |
| content: 'line1\n', | |
| textStyle: { | |
| weightedFontFamily: { fontFamily: 'Roboto Mono' }, | |
| }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| { | |
| paragraph: { | |
| elements: [ | |
| { | |
| textRun: { | |
| content: 'line2\n', | |
| textStyle: { | |
| weightedFontFamily: { fontFamily: 'Roboto Mono' }, | |
| }, | |
| }, | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('```'); | |
| expect(md).toContain('line1'); | |
| expect(md).toContain('line2'); | |
| }); | |
| }); | |
| describe('Tables', () => { | |
| it('should convert a simple table', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { | |
| table: { | |
| tableRows: [ | |
| { | |
| tableCells: [ | |
| { | |
| content: [{ paragraph: { elements: [{ textRun: { content: 'A\n' } }] } }], | |
| }, | |
| { | |
| content: [{ paragraph: { elements: [{ textRun: { content: 'B\n' } }] } }], | |
| }, | |
| ], | |
| }, | |
| { | |
| tableCells: [ | |
| { | |
| content: [{ paragraph: { elements: [{ textRun: { content: '1\n' } }] } }], | |
| }, | |
| { | |
| content: [{ paragraph: { elements: [{ textRun: { content: '2\n' } }] } }], | |
| }, | |
| ], | |
| }, | |
| ], | |
| }, | |
| }, | |
| ], | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('| A | B |'); | |
| expect(md).toContain('| --- | --- |'); | |
| expect(md).toContain('| 1 | 2 |'); | |
| }); | |
| }); | |
| describe('Section Breaks', () => { | |
| it('should convert section breaks to horizontal rules', () => { | |
| const doc = { | |
| body: { | |
| content: [ | |
| { paragraph: { elements: [{ textRun: { content: 'Before\n' } }] } }, | |
| { sectionBreak: {} }, | |
| { paragraph: { elements: [{ textRun: { content: 'After\n' } }] } }, | |
| ], | |
| }, | |
| }; | |
| const md = docsJsonToMarkdown(doc); | |
| expect(md).toContain('---'); | |
| expect(md).toContain('Before'); | |
| expect(md).toContain('After'); | |
| }); | |
| }); | |
| describe('Edge Cases', () => { | |
| it('should return empty string for empty document', () => { | |
| expect(docsJsonToMarkdown({})).toBe(''); | |
| expect(docsJsonToMarkdown({ body: {} })).toBe(''); | |
| expect(docsJsonToMarkdown({ body: { content: [] } })).toBe(''); | |
| }); | |
| it('should handle paragraphs with no text runs', () => { | |
| const doc = { | |
| body: { | |
| content: [{ paragraph: { elements: [] } }], | |
| }, | |
| }; | |
| expect(typeof docsJsonToMarkdown(doc)).toBe('string'); | |
| }); | |
| }); | |
| }); | |