// src/markdown-transformer/markdownToDocsRequests.ts
import { docs_v1 } from 'googleapis';
import MarkdownIt from 'markdown-it';
import type Token from 'markdown-it/lib/token.mjs';
import {
buildUpdateTextStyleRequest,
buildUpdateParagraphStyleRequest,
} from '../googleDocsApiHelpers.js';
import { MarkdownConversionError } from '../types.js';
// --- Markdown-it Setup ---
function createParser(): MarkdownIt {
return new MarkdownIt({
html: false,
linkify: true,
typographer: false,
breaks: false,
xhtmlOut: false,
});
}
function getLinkHref(token: Token): string | null {
if (token.type !== 'link_open') return null;
const hrefAttr = token.attrs?.find((attr: [string, string]) => attr[0] === 'href');
return hrefAttr ? hrefAttr[1] : null;
}
function getHeadingLevel(token: Token): number | null {
if (!token.type.startsWith('heading_')) return null;
const match = token.tag.match(/h(\d)/);
return match ? parseInt(match[1], 10) : null;
}
// --- Internal Types ---
interface TextRange {
startIndex: number;
endIndex: number;
formatting: FormattingState;
}
interface FormattingState {
bold?: boolean;
italic?: boolean;
strikethrough?: boolean;
link?: string;
code?: boolean;
}
interface ParagraphRange {
startIndex: number;
endIndex: number;
namedStyleType?: string;
}
interface ListState {
type: 'bullet' | 'ordered';
level: number;
}
interface PendingListItem {
startIndex: number;
endIndex?: number;
nestingLevel: number;
bulletPreset: 'NUMBERED_DECIMAL_ALPHA_ROMAN' | 'BULLET_DISC_CIRCLE_SQUARE' | 'BULLET_CHECKBOX';
taskPrefixProcessed: boolean;
}
interface CodeBlockRange {
tableStartIndex: number;
textStartIndex: number;
textEndIndex: number;
language?: string;
}
interface TableCellData {
text: string;
isHeader: boolean;
textRanges: { startIndex: number; endIndex: number; formatting: FormattingState }[];
}
interface TableState {
rows: TableCellData[][];
currentRow: TableCellData[];
inHeader: boolean;
currentCell: TableCellData | null;
}
export interface ConversionOptions {
/** Treat the first H1 (`# ...`) as a Google Docs TITLE instead of HEADING_1. Default false. */
firstHeadingAsTitle?: boolean;
}
interface ConversionContext {
currentIndex: number;
insertRequests: docs_v1.Schema$Request[];
formatRequests: docs_v1.Schema$Request[];
textRanges: TextRange[];
formattingStack: FormattingState[];
listStack: ListState[];
paragraphRanges: ParagraphRange[];
normalParagraphRanges: { startIndex: number; endIndex: number }[];
listSpacingRanges: { startIndex: number; endIndex: number }[];
pendingListItems: PendingListItem[];
openListItemStack: number[];
hrRanges: { startIndex: number; endIndex: number }[];
codeBlockRanges: CodeBlockRange[];
tableState?: TableState;
inTableCell: boolean;
tabId?: string;
currentParagraphStart?: number;
currentHeadingLevel?: number;
/** When firstHeadingAsTitle is on, tracks whether the title H1 has been consumed. */
titleConsumed: boolean;
firstHeadingAsTitle: boolean;
}
const CODE_FONT_FAMILY = 'Roboto Mono';
const CODE_TEXT_HEX = '#188038';
const CODE_BACKGROUND_HEX = '#F1F3F4';
// --- Code Block (table-based) Constants ---
// Google Docs "Code Block" building block is a styled 1x1 table.
// These constants define the visual style for programmatically created code blocks.
const CODE_BLOCK_BG_RGB = { red: 0.937, green: 0.945, blue: 0.953 }; // #EFF1F3
const CODE_BLOCK_BORDER_RGB = { red: 0.855, green: 0.863, blue: 0.878 }; // #DADCE0
// IMPORTANT: The Google Docs API always inserts a newline character ("\n") BEFORE
// the table when processing an insertTable request. So calling insertTable at index T
// produces the following document structure:
//
// T → paragraph break ("\n") — auto-inserted by the API
// T + 1 → table.startIndex (the actual table element)
// T + 2 → tableRow.startIndex
// T + 3 → tableCell.startIndex
// T + 4 → paragraph.startIndex ← cell content (text insertion point)
// T + 6 → table.endIndex
//
// Therefore:
// CELL_CONTENT_OFFSET = 4 (from insertTable target T to cell content at T+4)
// EMPTY_1x1_TABLE_SIZE = 6 (total positions: 1 newline + 5 table structure)
// Actual table start for updateTableCellStyle = T + 1 (NOT T)
//
// Verified empirically via documents.get on a real document with a 1x1 table.
const CELL_CONTENT_OFFSET = 4;
const EMPTY_1x1_TABLE_SIZE = 6;
// --- Main Conversion Function ---
/**
* Converts a markdown string to an array of Google Docs API batch update requests.
*
* This is an internal function -- callers should use `insertMarkdown()` from
* the barrel export instead.
*
* @param markdown - The markdown content to convert
* @param startIndex - The document index where content should be inserted (1-based)
* @param tabId - Optional tab ID for multi-tab documents
* @param options - Optional conversion options (e.g. firstHeadingAsTitle)
* @returns Array of Google Docs API requests (insertions first, then formatting)
*/
export function convertMarkdownToRequests(
markdown: string,
startIndex: number = 1,
tabId?: string,
options?: ConversionOptions
): docs_v1.Schema$Request[] {
if (!markdown || markdown.trim().length === 0) {
return [];
}
const parser = createParser();
const tokens = parser.parse(markdown, {});
const context: ConversionContext = {
currentIndex: startIndex,
insertRequests: [],
formatRequests: [],
textRanges: [],
formattingStack: [],
listStack: [],
paragraphRanges: [],
normalParagraphRanges: [],
listSpacingRanges: [],
pendingListItems: [],
openListItemStack: [],
hrRanges: [],
codeBlockRanges: [],
tableState: undefined,
inTableCell: false,
tabId,
titleConsumed: false,
firstHeadingAsTitle: options?.firstHeadingAsTitle ?? false,
};
try {
for (const token of tokens) {
processToken(token, context);
}
finalizeFormatting(context);
return [...context.insertRequests, ...context.formatRequests];
} catch (error) {
if (error instanceof MarkdownConversionError) {
throw error;
}
throw new MarkdownConversionError(
`Failed to convert markdown: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
// --- Token Processing ---
function processToken(token: Token, context: ConversionContext): void {
switch (token.type) {
// Headings
case 'heading_open':
handleHeadingOpen(token, context);
break;
case 'heading_close':
handleHeadingClose(context);
break;
// Paragraphs
case 'paragraph_open':
handleParagraphOpen(context);
break;
case 'paragraph_close':
handleParagraphClose(context);
break;
// Text content
case 'text':
handleTextToken(token, context);
break;
case 'code_inline':
handleCodeInlineToken(token, context);
break;
// Inline formatting
case 'strong_open':
context.formattingStack.push({ bold: true });
break;
case 'strong_close':
popFormatting(context, 'bold');
break;
case 'em_open':
context.formattingStack.push({ italic: true });
break;
case 'em_close':
popFormatting(context, 'italic');
break;
case 's_open':
context.formattingStack.push({ strikethrough: true });
break;
case 's_close':
popFormatting(context, 'strikethrough');
break;
// Links
case 'link_open': {
const href = getLinkHref(token);
if (href) {
context.formattingStack.push({ link: href });
}
break;
}
case 'link_close':
popFormatting(context, 'link');
break;
// Lists
case 'bullet_list_open':
context.listStack.push({ type: 'bullet', level: context.listStack.length });
break;
case 'bullet_list_close':
handleListClose(context);
break;
case 'ordered_list_open':
context.listStack.push({ type: 'ordered', level: context.listStack.length });
break;
case 'ordered_list_close':
handleListClose(context);
break;
case 'list_item_open':
handleListItemOpen(context);
break;
case 'list_item_close':
handleListItemClose(context);
break;
// Breaks
case 'softbreak':
if (context.inTableCell && context.tableState?.currentCell) {
context.tableState.currentCell.text += ' ';
} else {
insertText(' ', context);
}
break;
case 'hardbreak':
if (context.inTableCell && context.tableState?.currentCell) {
context.tableState.currentCell.text += '\n';
} else {
insertText('\n', context);
}
break;
// Inline container
case 'inline':
if (token.children) {
for (const child of token.children) {
processToken(child, context);
}
}
break;
// Tables
case 'table_open':
context.tableState = { rows: [], currentRow: [], inHeader: false, currentCell: null };
break;
case 'thead_open':
if (context.tableState) context.tableState.inHeader = true;
break;
case 'thead_close':
if (context.tableState) context.tableState.inHeader = false;
break;
case 'tbody_open':
case 'tbody_close':
break;
case 'tr_open':
if (context.tableState) context.tableState.currentRow = [];
break;
case 'tr_close':
if (context.tableState && context.tableState.currentRow.length > 0) {
context.tableState.rows.push([...context.tableState.currentRow]);
context.tableState.currentRow = [];
}
break;
case 'th_open':
case 'td_open': {
if (context.tableState) {
context.tableState.currentCell = {
text: '',
isHeader: context.tableState.inHeader || token.type === 'th_open',
textRanges: [],
};
context.inTableCell = true;
}
break;
}
case 'th_close':
case 'td_close':
if (context.tableState?.currentCell) {
context.tableState.currentRow.push(context.tableState.currentCell);
context.tableState.currentCell = null;
}
context.inTableCell = false;
break;
case 'table_close':
if (context.tableState) {
handleTableClose(context.tableState, context);
context.tableState = undefined;
context.inTableCell = false;
}
break;
// Code blocks
case 'fence':
case 'code_block':
handleCodeBlockToken(token, context);
break;
// Horizontal rules
case 'hr':
handleHorizontalRule(context);
break;
// Blockquotes (skip for now)
case 'blockquote_open':
case 'blockquote_close':
break;
default:
break;
}
}
// --- Heading Handlers ---
function handleHeadingOpen(token: Token, context: ConversionContext): void {
const level = getHeadingLevel(token);
if (level) {
context.currentHeadingLevel = level;
context.currentParagraphStart = context.currentIndex;
}
}
function handleHeadingClose(context: ConversionContext): void {
if (context.currentHeadingLevel && context.currentParagraphStart !== undefined) {
// When firstHeadingAsTitle is enabled, the very first H1 becomes a TITLE.
const useTitle =
context.firstHeadingAsTitle && !context.titleConsumed && context.currentHeadingLevel === 1;
if (useTitle) {
context.titleConsumed = true;
}
context.paragraphRanges.push({
startIndex: context.currentParagraphStart,
endIndex: context.currentIndex,
namedStyleType: useTitle ? 'TITLE' : `HEADING_${context.currentHeadingLevel}`,
});
insertText('\n', context);
context.currentHeadingLevel = undefined;
context.currentParagraphStart = undefined;
}
}
// --- Horizontal Rule ---
function handleHorizontalRule(context: ConversionContext): void {
if (!lastInsertEndsWithNewline(context)) {
insertText('\n', context);
}
const start = context.currentIndex;
insertText('\n', context);
context.hrRanges.push({ startIndex: start, endIndex: context.currentIndex });
}
// --- Paragraph Handlers ---
function handleParagraphOpen(context: ConversionContext): void {
if (context.listStack.length === 0) {
context.currentParagraphStart = context.currentIndex;
}
}
function handleParagraphClose(context: ConversionContext): void {
// Track normal (non-list) paragraph ranges for spacing
const paragraphStart = context.currentParagraphStart;
if (!lastInsertEndsWithNewline(context)) {
insertText('\n', context);
}
const currentListItem = getCurrentOpenListItem(context);
if (currentListItem) {
const paragraphEndIndex = lastInsertEndsWithNewline(context)
? context.currentIndex - 1
: context.currentIndex;
if (paragraphEndIndex > currentListItem.startIndex) {
currentListItem.endIndex = paragraphEndIndex;
}
}
// Record the range for normal paragraphs (not list items) so we can apply spacing later
if (paragraphStart !== undefined && context.listStack.length === 0) {
context.normalParagraphRanges.push({
startIndex: paragraphStart,
endIndex: context.currentIndex,
});
}
context.currentParagraphStart = undefined;
}
// --- List Handlers ---
function handleListItemOpen(context: ConversionContext): void {
if (context.listStack.length === 0) {
throw new MarkdownConversionError('List item found outside of list context');
}
const currentList = context.listStack[context.listStack.length - 1];
const itemStart = context.currentIndex;
if (currentList.level > 0) {
insertText('\t'.repeat(currentList.level), context);
}
const listItem: PendingListItem = {
startIndex: itemStart,
nestingLevel: currentList.level,
bulletPreset:
currentList.type === 'ordered' ? 'NUMBERED_DECIMAL_ALPHA_ROMAN' : 'BULLET_DISC_CIRCLE_SQUARE',
taskPrefixProcessed: false,
};
context.pendingListItems.push(listItem);
context.openListItemStack.push(context.pendingListItems.length - 1);
}
function handleListItemClose(context: ConversionContext): void {
const openIndex = context.openListItemStack.pop();
if (openIndex === undefined) return;
const listItem = context.pendingListItems[openIndex];
if (listItem.endIndex === undefined) {
const computedEndIndex = lastInsertEndsWithNewline(context)
? context.currentIndex - 1
: context.currentIndex;
if (computedEndIndex > listItem.startIndex) {
listItem.endIndex = computedEndIndex;
}
}
if (!lastInsertEndsWithNewline(context)) {
insertText('\n', context);
}
}
function handleListClose(context: ConversionContext): void {
context.listStack.pop();
// When a top-level list closes (stack becomes empty), record the range of the
// last list item's paragraph so we can apply spaceBelow to it. This creates a
// visible gap between the end of a list and the following content.
if (context.listStack.length === 0) {
// Find the last pending list item that has a valid endIndex
for (let i = context.pendingListItems.length - 1; i >= 0; i--) {
const item = context.pendingListItems[i];
if (item.endIndex !== undefined && item.endIndex > item.startIndex) {
context.listSpacingRanges.push({
startIndex: item.startIndex,
endIndex: item.endIndex,
});
break;
}
}
}
}
// --- Text Handling ---
function handleTextToken(token: Token, context: ConversionContext): void {
let text = token.content;
if (!text) return;
// Intercept text when inside a table cell — collect into cell buffer
if (context.inTableCell && context.tableState?.currentCell) {
const cell = context.tableState.currentCell;
const startIndex = cell.text.length;
cell.text += text;
const formatting = mergeFormattingStack(context.formattingStack);
if (hasFormatting(formatting)) {
cell.textRanges.push({ startIndex, endIndex: cell.text.length, formatting });
}
return;
}
const currentListItem = getCurrentOpenListItem(context);
if (currentListItem && !currentListItem.taskPrefixProcessed) {
currentListItem.taskPrefixProcessed = true;
const taskPrefixMatch = text.match(/^\[( |x|X)\]\s+/);
if (taskPrefixMatch) {
currentListItem.bulletPreset = 'BULLET_CHECKBOX';
text = text.slice(taskPrefixMatch[0].length);
if (!text) return;
}
}
const startIndex = context.currentIndex;
const endIndex = startIndex + text.length;
insertText(text, context);
const currentFormatting = mergeFormattingStack(context.formattingStack);
if (hasFormatting(currentFormatting)) {
context.textRanges.push({ startIndex, endIndex, formatting: currentFormatting });
}
}
function handleCodeInlineToken(token: Token, context: ConversionContext): void {
context.formattingStack.push({ code: true });
handleTextToken(token, context);
popFormatting(context, 'code');
}
function handleCodeBlockToken(token: Token, context: ConversionContext): void {
const normalizedContent = token.content.endsWith('\n')
? token.content.slice(0, -1)
: token.content;
const language = token.info?.trim() || undefined;
// Ensure previous content ends with a newline before inserting the table
if (context.insertRequests.length > 0 && !lastInsertEndsWithNewline(context)) {
insertText('\n', context);
}
const tableStartIndex = context.currentIndex;
// 1. Insert a 1x1 table (creates the table structure with an empty paragraph in the cell)
const tableLocation: Record<string, unknown> = { index: tableStartIndex };
if (context.tabId) tableLocation.tabId = context.tabId;
context.insertRequests.push({
insertTable: {
location: tableLocation as docs_v1.Schema$Location,
rows: 1,
columns: 1,
},
});
// 2. Insert code text into the cell paragraph
// For a 1x1 table at index N, the cell paragraph content starts at N + CELL_CONTENT_OFFSET
const cellContentIndex = tableStartIndex + CELL_CONTENT_OFFSET;
const textLength = normalizedContent.length;
if (textLength > 0) {
const cellLocation: Record<string, unknown> = { index: cellContentIndex };
if (context.tabId) cellLocation.tabId = context.tabId;
context.insertRequests.push({
insertText: {
location: cellLocation as docs_v1.Schema$Location,
text: normalizedContent,
},
});
}
// 3. Track the code block for table/text formatting in finalization
context.codeBlockRanges.push({
tableStartIndex,
textStartIndex: cellContentIndex,
textEndIndex: cellContentIndex + textLength,
language,
});
// 4. Advance currentIndex past the entire table structure
// Total table size = EMPTY_1x1_TABLE_SIZE + inserted text length
context.currentIndex = tableStartIndex + EMPTY_1x1_TABLE_SIZE + textLength;
// 5. Ensure a newline after the table for paragraph separation
insertText('\n', context);
}
// --- Table Handler ---
//
// Google Docs API table index layout for an R×C table inserted at T:
//
// T → newline (auto-inserted before table)
// T + 1 → table element
// T + 2 → row[0]
// T + 3 → cell[0][0]
// T + 4 → paragraph in cell[0][0] ← content insertion point
// T + 5 → cell[0][1] (for C > 1)
// T + 6 → paragraph in cell[0][1]
// ...
// T + 2+C → row[1] (for R > 1)
// ...
// T + end → table end
//
// Formulas (verified empirically via CELL_CONTENT_OFFSET and EMPTY_1x1_TABLE_SIZE):
// cellContentIndex(T, r, c, C) = T + 4 + r * (1 + 2*C) + 2*c
// emptyTableSize(R, C) = 3 + R * (1 + 2*C)
function handleTableClose(tableState: TableState, context: ConversionContext): void {
const rows = tableState.rows;
if (rows.length === 0) return;
const numRows = rows.length;
const numCols = rows.reduce((max, row) => Math.max(max, row.length), 0);
if (numCols === 0) return;
// Ensure newline before table
if (!lastInsertEndsWithNewline(context)) {
insertText('\n', context);
}
const tableStartIndex = context.currentIndex;
// Insert the table structure
const tableLocation: Record<string, unknown> = { index: tableStartIndex };
if (context.tabId) tableLocation.tabId = context.tabId;
context.insertRequests.push({
insertTable: {
location: tableLocation as docs_v1.Schema$Location,
rows: numRows,
columns: numCols,
},
});
// Insert text into each cell, tracking cumulative offset from prior insertions
let cumulativeTextLength = 0;
for (let r = 0; r < numRows; r++) {
for (let c = 0; c < numCols; c++) {
const cell = rows[r]?.[c];
if (!cell?.text) continue;
const baseCellIndex = tableStartIndex + 4 + r * (1 + 2 * numCols) + 2 * c;
const adjustedIndex = baseCellIndex + cumulativeTextLength;
const cellLocation: Record<string, unknown> = { index: adjustedIndex };
if (context.tabId) cellLocation.tabId = context.tabId;
context.insertRequests.push({
insertText: {
location: cellLocation as docs_v1.Schema$Location,
text: cell.text,
},
});
// Apply inline formatting (bold, italic, links, etc.) from within the cell
for (const range of cell.textRanges) {
const absStart = adjustedIndex + range.startIndex;
const absEnd = adjustedIndex + range.endIndex;
if (
range.formatting.bold ||
range.formatting.italic ||
range.formatting.strikethrough ||
range.formatting.code
) {
const styleReq = buildUpdateTextStyleRequest(
absStart,
absEnd,
{
bold: range.formatting.bold,
italic: range.formatting.italic,
strikethrough: range.formatting.strikethrough,
fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined,
foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined,
backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined,
},
context.tabId
);
if (styleReq) context.formatRequests.push(styleReq.request);
}
if (range.formatting.link) {
const linkReq = buildUpdateTextStyleRequest(
absStart,
absEnd,
{ linkUrl: range.formatting.link },
context.tabId
);
if (linkReq) context.formatRequests.push(linkReq.request);
}
}
// Bold all text in header cells
if (cell.isHeader && cell.text.length > 0) {
const headerStyleReq = buildUpdateTextStyleRequest(
adjustedIndex,
adjustedIndex + cell.text.length,
{ bold: true },
context.tabId
);
if (headerStyleReq) context.formatRequests.push(headerStyleReq.request);
}
cumulativeTextLength += cell.text.length;
}
}
// Advance currentIndex past the full table (empty structure + all inserted text)
const emptyTableSize = 3 + numRows * (1 + 2 * numCols);
context.currentIndex = tableStartIndex + emptyTableSize + cumulativeTextLength;
// Ensure newline after table
insertText('\n', context);
}
// --- Insert Helper ---
function insertText(text: string, context: ConversionContext): void {
const location: Record<string, unknown> = { index: context.currentIndex };
if (context.tabId) {
location.tabId = context.tabId;
}
context.insertRequests.push({
insertText: { location: location as docs_v1.Schema$Location, text },
});
context.currentIndex += text.length;
}
// --- Formatting Stack ---
function mergeFormattingStack(stack: FormattingState[]): FormattingState {
const merged: FormattingState = {};
for (const state of stack) {
if (state.bold !== undefined) merged.bold = state.bold;
if (state.italic !== undefined) merged.italic = state.italic;
if (state.strikethrough !== undefined) merged.strikethrough = state.strikethrough;
if (state.code !== undefined) merged.code = state.code;
if (state.link !== undefined) merged.link = state.link;
}
return merged;
}
function hasFormatting(formatting: FormattingState): boolean {
return (
formatting.bold === true ||
formatting.italic === true ||
formatting.strikethrough === true ||
formatting.code === true ||
formatting.link !== undefined
);
}
function popFormatting(context: ConversionContext, type: keyof FormattingState): void {
for (let i = context.formattingStack.length - 1; i >= 0; i--) {
if (context.formattingStack[i][type] !== undefined) {
context.formattingStack.splice(i, 1);
break;
}
}
}
// --- Finalization ---
function finalizeFormatting(context: ConversionContext): void {
// Character-level formatting (bold, italic, strikethrough, code, links)
for (const range of context.textRanges) {
const rangeLocation: docs_v1.Schema$Range = {
startIndex: range.startIndex,
endIndex: range.endIndex,
};
if (context.tabId) {
rangeLocation.tabId = context.tabId;
}
if (
range.formatting.bold ||
range.formatting.italic ||
range.formatting.strikethrough ||
range.formatting.code
) {
const styleRequest = buildUpdateTextStyleRequest(
range.startIndex,
range.endIndex,
{
bold: range.formatting.bold,
italic: range.formatting.italic,
strikethrough: range.formatting.strikethrough,
fontFamily: range.formatting.code ? CODE_FONT_FAMILY : undefined,
foregroundColor: range.formatting.code ? CODE_TEXT_HEX : undefined,
backgroundColor: range.formatting.code ? CODE_BACKGROUND_HEX : undefined,
},
context.tabId
);
if (styleRequest) {
context.formatRequests.push(styleRequest.request);
}
}
if (range.formatting.link) {
const linkRequest = buildUpdateTextStyleRequest(
range.startIndex,
range.endIndex,
{ linkUrl: range.formatting.link },
context.tabId
);
if (linkRequest) {
context.formatRequests.push(linkRequest.request);
}
}
}
// Paragraph-level formatting (headings)
for (const paraRange of context.paragraphRanges) {
if (paraRange.namedStyleType) {
const paraRequest = buildUpdateParagraphStyleRequest(
paraRange.startIndex,
paraRange.endIndex,
{ namedStyleType: paraRange.namedStyleType as any },
context.tabId
);
if (paraRequest) {
context.formatRequests.push(paraRequest.request);
}
}
}
// Normal paragraph spacing (spaceBelow so paragraphs have visible gaps between them,
// matching the visual separation expected from markdown-rendered paragraphs).
// The default Google Docs NORMAL_TEXT style has 0pt spacing, so without this
// paragraphs would appear crammed together with no gap.
for (const normalRange of context.normalParagraphRanges) {
const range: docs_v1.Schema$Range = {
startIndex: normalRange.startIndex,
endIndex: normalRange.endIndex,
};
if (context.tabId) {
range.tabId = context.tabId;
}
context.formatRequests.push({
updateParagraphStyle: {
range,
paragraphStyle: {
spaceBelow: { magnitude: 8, unit: 'PT' },
},
fields: 'spaceBelow',
},
});
}
// List trailing spacing: apply spaceBelow to the last paragraph of each
// top-level list so there is a visible gap between the list and the content
// that follows it.
for (const listRange of context.listSpacingRanges) {
const range: docs_v1.Schema$Range = {
startIndex: listRange.startIndex,
endIndex: listRange.endIndex,
};
if (context.tabId) {
range.tabId = context.tabId;
}
context.formatRequests.push({
updateParagraphStyle: {
range,
paragraphStyle: {
spaceBelow: { magnitude: 8, unit: 'PT' },
},
fields: 'spaceBelow',
},
});
}
// Code block table formatting (1x1 table with background + monospace text)
for (const codeBlock of context.codeBlockRanges) {
// The actual table element starts at tableStartIndex + 1 because insertTable
// auto-inserts a preceding newline at tableStartIndex (see constants comment above).
const tableStartLocation: Record<string, unknown> = { index: codeBlock.tableStartIndex + 1 };
if (context.tabId) tableStartLocation.tabId = context.tabId;
// Style the text inside the cell as monospace
if (codeBlock.textEndIndex > codeBlock.textStartIndex) {
const codeTextStyle = buildUpdateTextStyleRequest(
codeBlock.textStartIndex,
codeBlock.textEndIndex,
{ fontFamily: CODE_FONT_FAMILY },
context.tabId
);
if (codeTextStyle) {
context.formatRequests.push(codeTextStyle.request);
}
}
// Set cell background color to light gray
const borderStyle = {
color: { color: { rgbColor: CODE_BLOCK_BORDER_RGB } },
width: { magnitude: 0.5, unit: 'PT' },
dashStyle: 'SOLID',
};
context.formatRequests.push({
updateTableCellStyle: {
tableRange: {
tableCellLocation: {
tableStartLocation: tableStartLocation as docs_v1.Schema$Location,
rowIndex: 0,
columnIndex: 0,
},
rowSpan: 1,
columnSpan: 1,
},
tableCellStyle: {
backgroundColor: { color: { rgbColor: CODE_BLOCK_BG_RGB } },
paddingTop: { magnitude: 8, unit: 'PT' },
paddingBottom: { magnitude: 8, unit: 'PT' },
paddingLeft: { magnitude: 12, unit: 'PT' },
paddingRight: { magnitude: 12, unit: 'PT' },
borderTop: borderStyle,
borderBottom: borderStyle,
borderLeft: borderStyle,
borderRight: borderStyle,
},
fields:
'backgroundColor,paddingTop,paddingBottom,paddingLeft,paddingRight,borderTop,borderBottom,borderLeft,borderRight',
},
});
}
// Horizontal rule styling (bottom border on empty paragraphs)
for (const hrRange of context.hrRanges) {
const range: docs_v1.Schema$Range = {
startIndex: hrRange.startIndex,
endIndex: hrRange.endIndex,
};
if (context.tabId) {
range.tabId = context.tabId;
}
context.formatRequests.push({
updateParagraphStyle: {
range,
paragraphStyle: {
borderBottom: {
color: {
color: { rgbColor: { red: 0.75, green: 0.75, blue: 0.75 } },
},
width: { magnitude: 1, unit: 'PT' },
padding: { magnitude: 6, unit: 'PT' },
dashStyle: 'SOLID',
},
},
fields: 'borderBottom',
},
});
}
// List formatting: merge *adjacent* items of the same bullet type into single
// ranges so Google Docs treats them as one list (with sequential numbering).
// Items are only merged when they're truly adjacent (gap of at most 1 char
// for the newline between them). Separate lists with paragraphs, headings, or
// other content between them must NOT be merged, otherwise
// createParagraphBullets would turn all intervening content into bullets.
const validListItems = context.pendingListItems
.filter((item) => item.endIndex !== undefined && item.endIndex > item.startIndex)
.sort((a, b) => a.startIndex - b.startIndex);
const mergedListRanges: { startIndex: number; endIndex: number; bulletPreset: string }[] = [];
for (const item of validListItems) {
const last = mergedListRanges[mergedListRanges.length - 1];
if (last && last.bulletPreset === item.bulletPreset && item.startIndex <= last.endIndex + 1) {
last.endIndex = Math.max(last.endIndex, item.endIndex!);
} else {
mergedListRanges.push({
startIndex: item.startIndex,
endIndex: item.endIndex!,
bulletPreset: item.bulletPreset,
});
}
}
// Apply bottom-to-top to avoid index shifts from tab consumption
mergedListRanges.sort((a, b) => b.startIndex - a.startIndex);
for (const merged of mergedListRanges) {
const rangeLocation: docs_v1.Schema$Range = {
startIndex: merged.startIndex,
endIndex: merged.endIndex,
};
if (context.tabId) {
rangeLocation.tabId = context.tabId;
}
context.formatRequests.push({
createParagraphBullets: {
range: rangeLocation,
bulletPreset: merged.bulletPreset,
},
});
}
}
// --- Utility ---
function getCurrentOpenListItem(context: ConversionContext): PendingListItem | null {
const openIndex = context.openListItemStack[context.openListItemStack.length - 1];
if (openIndex === undefined) return null;
return context.pendingListItems[openIndex] ?? null;
}
function lastInsertEndsWithNewline(context: ConversionContext): boolean {
const lastInsert = context.insertRequests[context.insertRequests.length - 1]?.insertText?.text;
return Boolean(lastInsert && lastInsert.endsWith('\n'));
}