Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ The extension provides the following tools:
- `docs.getText`: Retrieves the text content of a Google Doc.
- `docs.replaceText`: Replaces all occurrences of a given text with new text in
a Google Doc.
- `docs.appendMarkdown`: Appends markdown-formatted content as natively
formatted Docs text (headings, bold/italic/strikethrough/code, links, lists)
in a single call.
- `docs.formatText`: Applies formatting (bold, italic, headings, etc.) to text
ranges in a Google Doc.

Expand Down
179 changes: 179 additions & 0 deletions workspace-server/src/__tests__/services/DocsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1158,4 +1158,183 @@ describe('DocsService', () => {
});
});
});

describe('appendMarkdown', () => {
const emptyDocTabs = {
data: {
tabs: [
{
tabProperties: { tabId: 't.0' },
documentTab: {
body: { content: [{ startIndex: 1, endIndex: 2 }] },
},
},
],
},
};

it('inserts stripped text once and styles headings, inline ranges, and merged bullets', async () => {
mockDocsAPI.documents.get.mockResolvedValue(emptyDocTabs);
mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });

const result = await docsService.appendMarkdown({
documentId: 'test-doc-id',
markdown: '# Title\nHello **world**\n- a\n- b',
});

expect(mockDocsAPI.documents.batchUpdate).toHaveBeenCalledTimes(2);

const insertCall = mockDocsAPI.documents.batchUpdate.mock.calls[0][0];
expect(insertCall.requestBody.requests).toEqual([
{
insertText: {
location: { index: 1 },
text: 'Title\nHello world\na\nb\n',
},
},
]);

const styleCall = mockDocsAPI.documents.batchUpdate.mock.calls[1][0];
expect(styleCall.requestBody.requests).toEqual([
{
updateParagraphStyle: {
range: { startIndex: 1, endIndex: 7 },
paragraphStyle: { namedStyleType: 'HEADING_1' },
fields: 'namedStyleType',
},
},
{
updateTextStyle: {
range: { startIndex: 13, endIndex: 18 },
textStyle: { bold: true },
fields: 'bold',
},
},
{
createParagraphBullets: {
range: { startIndex: 19, endIndex: 23 },
bulletPreset: 'BULLET_DISC_CIRCLE_SQUARE',
},
},
]);

const payload = JSON.parse(result.content[0].text);
expect(payload).toEqual({
documentId: 'test-doc-id',
blocksProcessed: 4,
headings: 1,
styledRanges: 1,
bulletLists: 1,
skippedHorizontalRules: 0,
});
});

it('threads tabId through locations and ranges', async () => {
mockDocsAPI.documents.get.mockResolvedValue({
data: {
tabs: [
{
tabProperties: { tabId: 't.0' },
documentTab: {
body: { content: [{ startIndex: 1, endIndex: 2 }] },
},
},
{
tabProperties: { tabId: 't.target' },
documentTab: {
body: { content: [{ startIndex: 1, endIndex: 10 }] },
},
},
],
},
});
mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });

await docsService.appendMarkdown({
documentId: 'test-doc-id',
markdown: '# Hi',
tabId: 't.target',
});

const insertCall = mockDocsAPI.documents.batchUpdate.mock.calls[0][0];
const insertReq = insertCall.requestBody.requests[0].insertText;
expect(insertReq.location).toEqual({ index: 9, tabId: 't.target' });
// Non-empty tab: a newline is prepended so the heading does not merge
// into the existing last paragraph, shifting the style range by 1.
expect(insertReq.text).toBe('\nHi\n');
const styleCall = mockDocsAPI.documents.batchUpdate.mock.calls[1][0];
expect(
styleCall.requestBody.requests[0].updateParagraphStyle.range,
).toEqual({ startIndex: 10, endIndex: 13, tabId: 't.target' });
});

it('skips the separator when the trailing paragraph is already empty', async () => {
mockDocsAPI.documents.get.mockResolvedValue({
data: {
tabs: [
{
tabProperties: { tabId: 't.0' },
documentTab: {
body: { content: [{ startIndex: 8, endIndex: 9 }] },
},
},
],
},
});
mockDocsAPI.documents.batchUpdate.mockResolvedValue({ data: {} });

await docsService.appendMarkdown({
documentId: 'test-doc-id',
markdown: 'plain',
});

const insertCall = mockDocsAPI.documents.batchUpdate.mock.calls[0][0];
expect(insertCall.requestBody.requests[0].insertText).toEqual({
location: { index: 8 },
text: 'plain\n',
});
});

it('errors when the tab is not found', async () => {
mockDocsAPI.documents.get.mockResolvedValue(emptyDocTabs);

const result = await docsService.appendMarkdown({
documentId: 'test-doc-id',
markdown: 'hello',
tabId: 't.missing',
});

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Tab with ID t.missing');
expect(mockDocsAPI.documents.batchUpdate).not.toHaveBeenCalled();
});

it('returns zero counts without API calls when only horizontal rules remain', async () => {
const result = await docsService.appendMarkdown({
documentId: 'test-doc-id',
markdown: '---\n\n***',
});

expect(mockDocsAPI.documents.get).not.toHaveBeenCalled();
expect(mockDocsAPI.documents.batchUpdate).not.toHaveBeenCalled();
const payload = JSON.parse(result.content[0].text);
expect(payload.blocksProcessed).toBe(0);
expect(payload.skippedHorizontalRules).toBe(2);
});

it('handles errors gracefully', async () => {
mockDocsAPI.documents.get.mockResolvedValue(emptyDocTabs);
mockDocsAPI.documents.batchUpdate.mockRejectedValue(
new Error('Markdown Error'),
);

const result = await docsService.appendMarkdown({
documentId: 'test-doc-id',
markdown: 'hello',
});

expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Markdown Error');
});
});
});
109 changes: 109 additions & 0 deletions workspace-server/src/__tests__/utils/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from '@jest/globals';
import { parseMarkdownBlocks, stripInlineMarkdown } from '../../utils/markdown';

describe('stripInlineMarkdown', () => {
it('strips bold, italic, strikethrough, and code markers and records ranges', () => {
const out = stripInlineMarkdown('a **b** *c* ~~d~~ `e`');
expect(out.text).toBe('a b c d e');
expect(out.formats).toEqual([
{ type: 'bold', start: 2, end: 3 },
{ type: 'italic', start: 4, end: 5 },
{ type: 'strikethrough', start: 6, end: 7 },
{ type: 'code', start: 8, end: 9 },
]);
});

it('records links with their URL', () => {
const out = stripInlineMarkdown('see [the docs](https://example.com) now');
expect(out.text).toBe('see the docs now');
expect(out.formats).toEqual([
{ type: 'link', start: 4, end: 12, url: 'https://example.com' },
]);
});

it('supports underscore italics', () => {
const out = stripInlineMarkdown('_hi_');
expect(out.text).toBe('hi');
expect(out.formats).toEqual([{ type: 'italic', start: 0, end: 2 }]);
});

it('supports double-underscore bold', () => {
const out = stripInlineMarkdown('__hi__');
expect(out.text).toBe('hi');
expect(out.formats).toEqual([{ type: 'bold', start: 0, end: 2 }]);
});

it('leaves markers with no closer as literal text', () => {
const out = stripInlineMarkdown('a *b and c');
expect(out.text).toBe('a *b and c');
expect(out.formats).toEqual([]);
});

it('degrades an unclosed double marker to a literal star plus a paired single marker', () => {
// "**" has no closing "**", so the first star stays literal and the
// second pairs with the lone star later in the line.
const out = stripInlineMarkdown('a **b and *c');
expect(out.text).toBe('a *b and c');
expect(out.formats).toEqual([{ type: 'italic', start: 3, end: 9 }]);
});

it('treats empty markers as literal text', () => {
const out = stripInlineMarkdown('****');
expect(out.text).toBe('****');
expect(out.formats).toEqual([]);
});
});

describe('parseMarkdownBlocks', () => {
it('parses headings with levels and stripped inline formats', () => {
const blocks = parseMarkdownBlocks('# Title\n###### **Deep**');
expect(blocks).toEqual([
{ type: 'heading', level: 1, text: 'Title', formats: [] },
{
type: 'heading',
level: 6,
text: 'Deep',
formats: [{ type: 'bold', start: 0, end: 4 }],
},
]);
});

it('emits one block per list item, allowing leading whitespace', () => {
const blocks = parseMarkdownBlocks('- a\n - b\n1. one\n2. two');
expect(blocks).toEqual([
{ type: 'bullet', text: 'a', formats: [] },
{ type: 'bullet', text: 'b', formats: [] },
{ type: 'numbered', text: 'one', formats: [] },
{ type: 'numbered', text: 'two', formats: [] },
]);
});

it('parses horizontal rules and skips blank lines', () => {
const blocks = parseMarkdownBlocks('\n---\n\n***\n');
expect(blocks).toEqual([{ type: 'hr' }, { type: 'hr' }]);
});

it('falls through to paragraphs (including pipe rows, which are not parsed as tables)', () => {
const blocks = parseMarkdownBlocks('plain text\n| a | b |');
expect(blocks).toEqual([
{ type: 'paragraph', text: 'plain text', formats: [] },
{ type: 'paragraph', text: '| a | b |', formats: [] },
]);
});

it('parses a mixed document in order', () => {
const blocks = parseMarkdownBlocks('# T\n\nintro **x**\n\n- a\n- b');
expect(blocks.map((b) => b.type)).toEqual([
'heading',
'paragraph',
'bullet',
'bullet',
]);
});
});
1 change: 1 addition & 0 deletions workspace-server/src/features/feature-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [
'docs.writeText',
'docs.replaceText',
'docs.formatText',
'docs.appendMarkdown',
],
defaultEnabled: true,
},
Expand Down
19 changes: 19 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,25 @@ async function main() {
docsService.formatText,
);

registerTool(
'docs.appendMarkdown',
{
description:
'Appends markdown-formatted content to a Google Doc as natively formatted text in one call: headings (#-######), **bold**, *italic*, ~~strikethrough~~, `inline code`, [links](url), bullet (- item) and numbered (1. item) lists. Prefer this over docs.writeText + docs.formatText when writing new formatted content, since no character-index math is needed. Horizontal rules are skipped; tables degrade to plain text.',
inputSchema: {
documentId: z.string().describe('The ID of the document to modify.'),
markdown: z.string().min(1).describe('The markdown content to append.'),
tabId: z
.string()
.optional()
.describe(
'The ID of the tab to append to. If not provided, appends to the first tab.',
),
},
},
docsService.appendMarkdown,
);

// Slides tools
registerTool(
'slides.getText',
Expand Down
Loading
Loading