Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/main/services/DocsToMarkdownConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/

import { delimit, escapeText } from '@shared/markdown/inlineMarks';
import { DOCS_LINE_BREAK } from '@shared/constants';
import { CODE_FONT_FAMILY } from './DocsDocumentBuilder';
import type {
GDocsApiDocument,
Expand Down Expand Up @@ -51,7 +52,10 @@ function readRuns(el: GDocsStructuralElement): Run[] {
const ts = (pe.textRun?.textStyle ?? {}) as StyleBag;
const family = (ts['weightedFontFamily'] as { fontFamily?: string } | undefined)?.fontFamily;
runs.push({
text: content,
// A vertical tab is a line break inside the paragraph; markdown spells
// that as two spaces and a newline. Leaving it as a raw control
// character would show up as a difference on every later sync.
text: content.split(DOCS_LINE_BREAK).join(' \n'),
bold: ts['bold'] === true,
italic: ts['italic'] === true,
strikethrough: ts['strikethrough'] === true,
Expand Down
12 changes: 11 additions & 1 deletion src/main/services/MarkdownToDocsConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* to build an array of DocsElement objects suitable for the Google Docs API.
*/
import MarkdownIt from 'markdown-it';
import { DOCS_LINE_BREAK } from '@shared/constants';
import type Token from 'markdown-it/lib/token.mjs';
import type { DocsDocument, DocsElement, DocsTextRun } from '@shared/types/google-docs';

Expand Down Expand Up @@ -62,9 +63,18 @@ function parseInlineTokens(children: Token[]): DocsTextRun[] {
case 'link_close':
link = undefined;
break;
// A newline inside a run is not a line break to Docs: insertText ends
// the paragraph there and starts another. Every later paragraph then
// fails to match its model element, so nothing is styled and the text
// keeps whatever it inherited. A soft-wrapped source file was enough to
// lose the formatting of a whole document.
case 'softbreak':
// Markdown renders a wrapped line as a single space.
runs.push({ text: ' ' });
break;
case 'hardbreak':
runs.push({ text: '\n' });
// Docs spells a within-paragraph line break as a vertical tab.
runs.push({ text: DOCS_LINE_BREAK });
break;
case 'image':
// Inline images: use alt text from children content, src from attrs
Expand Down
8 changes: 8 additions & 0 deletions src/shared/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,11 @@ export const THEMES = {
GITHUB_LIGHT: 'github-light',
GITHUB_DARK: 'github-dark',
} as const;

/**
* How Google Docs spells a line break inside a paragraph (SHIFT+ENTER).
*
* A plain newline is not one: insertText ends the paragraph there and starts
* another, which breaks every later paragraph's match against the model.
*/
export const DOCS_LINE_BREAK = '\u000b';
21 changes: 21 additions & 0 deletions tests/unit/main/services/DocsToMarkdownConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,24 @@ describe('artefacts that make a document differ from itself', () => {
expect(md).toContain('\\~~b');
});
});

describe('a line break inside a paragraph', () => {
it('comes back as a markdown hard break, not a control character', () => {
// Docs spells a within-paragraph break as a vertical tab. Left raw it
// would read as a difference against the file on every later sync.
const md = convertDocsToMarkdown({
body: {
content: [
{
startIndex: 1,
endIndex: 26,
paragraph: { elements: [{ textRun: { content: 'First line.\u000bSecond line.\n' } }] },
},
],
},
});

expect(md).not.toContain('\u000b');
expect(md).toContain('First line. \nSecond line.');
});
});
38 changes: 38 additions & 0 deletions tests/unit/main/services/MarkdownToDocsConverter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { convertMarkdownToDocs } from '@main/services/MarkdownToDocsConverter';
import { flattenElements, getLeafText } from '@main/services/DocsDocumentBuilder';

describe('MarkdownToDocsConverter', () => {
it('should convert a plain paragraph', () => {
Expand Down Expand Up @@ -171,3 +172,40 @@ describe('a table written directly under the line above it', () => {
expect(doc.elements.map((e) => e.type)).toEqual(['list_item']);
});
});

describe('line breaks inside a paragraph', () => {
// A newline in a run's text is not a line break to Docs -- insertText splits
// it into a whole new paragraph. Every later paragraph then fails to match
// its model element, so nothing gets styled and the inserted text keeps
// whatever heading style it inherited. A soft-wrapped source file was enough
// to lose the formatting of an entire document.
const leafText = (md: string): string[] =>
flattenElements(convertMarkdownToDocs(md).elements).map((e) => getLeafText(e));

it('joins a soft-wrapped line with a space, as markdown renders it', () => {
expect(leafText('Scaffolds the thing if absent,\nthen reloads Caddy.\n')).toEqual([
'Scaffolds the thing if absent, then reloads Caddy.',
]);
});

it('keeps a hard break inside the paragraph, as Docs spells it', () => {
// Docs writes a within-paragraph line break as a vertical tab.
expect(leafText('First line. \nSecond line.\n')).toEqual([
'First line.\u000bSecond line.',
]);
});

it('never leaves a newline in an inline element', () => {
const md = [
'# A heading that\nwraps',
'',
'- a list item that\n wraps too',
'',
'Body text that\nwraps as well.',
].join('\n');

for (const text of leafText(md)) {
expect(text).not.toContain('\n');
}
});
});