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');
});
});
});