import Audio from '~/content_editor/extensions/audio';
import Blockquote from '~/content_editor/extensions/blockquote';
import Bold from '~/content_editor/extensions/bold';
import BulletList from '~/content_editor/extensions/bullet_list';
import Code from '~/content_editor/extensions/code';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import DescriptionItem from '~/content_editor/extensions/description_item';
import DescriptionList from '~/content_editor/extensions/description_list';
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
import Emoji from '~/content_editor/extensions/emoji';
import Figure from '~/content_editor/extensions/figure';
import FigureCaption from '~/content_editor/extensions/figure_caption';
import FootnoteDefinition from '~/content_editor/extensions/footnote_definition';
import FootnoteReference from '~/content_editor/extensions/footnote_reference';
import HardBreak from '~/content_editor/extensions/hard_break';
import Heading from '~/content_editor/extensions/heading';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
import HTMLMarks from '~/content_editor/extensions/html_marks';
import HTMLNodes from '~/content_editor/extensions/html_nodes';
import Image from '~/content_editor/extensions/image';
import InlineDiff from '~/content_editor/extensions/inline_diff';
import Italic from '~/content_editor/extensions/italic';
import Link from '~/content_editor/extensions/link';
import ListItem from '~/content_editor/extensions/list_item';
import OrderedList from '~/content_editor/extensions/ordered_list';
import Paragraph from '~/content_editor/extensions/paragraph';
import ReferenceDefinition from '~/content_editor/extensions/reference_definition';
import Sourcemap from '~/content_editor/extensions/sourcemap';
import Strike from '~/content_editor/extensions/strike';
import Table from '~/content_editor/extensions/table';
import TableCell from '~/content_editor/extensions/table_cell';
import TableHeader from '~/content_editor/extensions/table_header';
import TableRow from '~/content_editor/extensions/table_row';
import TaskItem from '~/content_editor/extensions/task_item';
import TaskList from '~/content_editor/extensions/task_list';
import Video from '~/content_editor/extensions/video';
import markdownSerializer from '~/content_editor/services/markdown_serializer';
import remarkMarkdownDeserializer from '~/content_editor/services/remark_markdown_deserializer';
import { createTestEditor, createDocBuilder } from '../test_utils';
jest.mock('~/emoji');
const tiptapEditor = createTestEditor({
extensions: [
Audio,
Blockquote,
Bold,
BulletList,
Code,
CodeBlockHighlight,
DescriptionItem,
DescriptionList,
Details,
DetailsContent,
Emoji,
FootnoteDefinition,
FootnoteReference,
Figure,
FigureCaption,
HardBreak,
Heading,
HorizontalRule,
Image,
InlineDiff,
Italic,
Link,
ListItem,
OrderedList,
ReferenceDefinition,
Sourcemap,
Strike,
Table,
TableCell,
TableHeader,
TableRow,
TaskItem,
TaskList,
Video,
...HTMLMarks,
...HTMLNodes,
],
});
const {
builders: {
audio,
doc,
blockquote,
bold,
bulletList,
code,
codeBlock,
details,
detailsContent,
div,
descriptionItem,
descriptionList,
emoji,
footnoteDefinition,
footnoteReference,
figure,
figureCaption,
heading,
hardBreak,
horizontalRule,
image,
inlineDiff,
italic,
link,
listItem,
orderedList,
paragraph,
referenceDefinition,
strike,
table,
tableCell,
tableHeader,
tableRow,
taskItem,
taskList,
video,
},
} = createDocBuilder({
tiptapEditor,
names: {
blockquote: { nodeType: Blockquote.name },
bold: { markType: Bold.name },
bulletList: { nodeType: BulletList.name },
code: { markType: Code.name },
codeBlock: { nodeType: CodeBlockHighlight.name },
details: { nodeType: Details.name },
detailsContent: { nodeType: DetailsContent.name },
descriptionItem: { nodeType: DescriptionItem.name },
descriptionList: { nodeType: DescriptionList.name },
emoji: { markType: Emoji.name },
figure: { nodeType: Figure.name },
figureCaption: { nodeType: FigureCaption.name },
footnoteDefinition: { nodeType: FootnoteDefinition.name },
footnoteReference: { nodeType: FootnoteReference.name },
hardBreak: { nodeType: HardBreak.name },
heading: { nodeType: Heading.name },
horizontalRule: { nodeType: HorizontalRule.name },
image: { nodeType: Image.name },
inlineDiff: { markType: InlineDiff.name },
italic: { nodeType: Italic.name },
link: { markType: Link.name },
listItem: { nodeType: ListItem.name },
orderedList: { nodeType: OrderedList.name },
paragraph: { nodeType: Paragraph.name },
referenceDefinition: { nodeType: ReferenceDefinition.name },
strike: { markType: Strike.name },
table: { nodeType: Table.name },
tableCell: { nodeType: TableCell.name },
tableHeader: { nodeType: TableHeader.name },
tableRow: { nodeType: TableRow.name },
taskItem: { nodeType: TaskItem.name },
taskList: { nodeType: TaskList.name },
...HTMLNodes.reduce(
(builders, htmlNode) => ({
...builders,
[htmlNode.name]: { nodeType: htmlNode.name },
}),
{},
),
},
});
const serialize = (...content) =>
markdownSerializer({}).serialize({
doc: doc(...content),
});
describe('markdownSerializer', () => {
it('correctly serializes bold', () => {
expect(serialize(paragraph(bold('bold')))).toBe('**bold**');
});
it('correctly serializes italics', () => {
expect(serialize(paragraph(italic('italics')))).toBe('_italics_');
});
it('correctly serializes code blocks wrapped by italics and bold marks', () => {
const text = 'code block';
expect(serialize(paragraph(italic(code(text))))).toBe(`_\`${text}\`_`);
expect(serialize(paragraph(code(italic(text))))).toBe(`_\`${text}\`_`);
expect(serialize(paragraph(bold(code(text))))).toBe(`**\`${text}\`**`);
expect(serialize(paragraph(code(bold(text))))).toBe(`**\`${text}\`**`);
expect(serialize(paragraph(strike(code(text))))).toBe(`~~\`${text}\`~~`);
expect(serialize(paragraph(code(strike(text))))).toBe(`~~\`${text}\`~~`);
});
it('correctly serializes inline diff', () => {
expect(
serialize(
paragraph(
inlineDiff({ type: 'addition' }, '+30 lines'),
inlineDiff({ type: 'deletion' }, '-10 lines'),
),
),
).toBe('{++30 lines+}{--10 lines-}');
});
it('correctly serializes a line break', () => {
expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld');
});
it('correctly serializes a link', () => {
expect(serialize(paragraph(link({ href: 'https://example.com' }, 'example url')))).toBe(
'[example url](https://example.com)',
);
});
it('correctly serializes a plain URL link', () => {
expect(serialize(paragraph(link({ href: 'https://example.com' }, 'https://example.com')))).toBe(
'https://example.com',
);
});
it('correctly serializes a link with a title', () => {
expect(
serialize(
paragraph(link({ href: 'https://example.com', title: 'click this link' }, 'example url')),
),
).toBe('[example url](https://example.com "click this link")');
});
it('correctly serializes a plain URL link with a title', () => {
expect(
serialize(
paragraph(
link({ href: 'https://example.com', title: 'link title' }, 'https://example.com'),
),
),
).toBe('[https://example.com](https://example.com "link title")');
});
it('correctly serializes a link with a canonicalSrc', () => {
expect(
serialize(
paragraph(
link(
{
href: '/uploads/abcde/file.zip',
canonicalSrc: 'file.zip',
title: 'click here to download',
},
'download file',
),
),
),
).toBe('[download file](file.zip "click here to download")');
});
it('correctly serializes link references', () => {
expect(
serialize(
paragraph(
link(
{
href: 'gitlab-url',
isReference: true,
},
'GitLab',
),
),
),
).toBe('[GitLab][gitlab-url]');
});
it('correctly serializes image references', () => {
expect(
serialize(
paragraph(
image({
canonicalSrc: 'gitlab-url',
src: 'image.svg',
alt: 'GitLab',
isReference: true,
}),
),
),
).toBe('![GitLab][gitlab-url]');
});
it('correctly serializes strikethrough', () => {
expect(serialize(paragraph(strike('deleted content')))).toBe('~~deleted content~~');
});
it('correctly serializes blockquotes with hard breaks', () => {
expect(serialize(blockquote('some text', hardBreak(), hardBreak(), 'new line'))).toBe(
`
> some text\\
> \\
> new line
`.trim(),
);
});
it('correctly serializes blockquote with multiple block nodes', () => {
expect(serialize(blockquote(paragraph('some paragraph'), codeBlock('var x = 10;')))).toBe(
`
> some paragraph
>
> \`\`\`
> var x = 10;
> \`\`\`
`.trim(),
);
});
it('correctly serializes a multiline blockquote', () => {
expect(
serialize(
blockquote(
{ multiline: true },
paragraph('some paragraph with ', bold('bold')),
codeBlock('var y = 10;'),
),
),
).toBe(
`
>>>
some paragraph with **bold**
\`\`\`
var y = 10;
\`\`\`
>>>
`.trim(),
);
});
it('correctly serializes a code block with language', () => {
expect(
serialize(
codeBlock(
{ language: 'json' },
'this is not really json but just trying out whether this case works or not',
),
),
).toBe(
`
\`\`\`json
this is not really json but just trying out whether this case works or not
\`\`\`
`.trim(),
);
});
it('correctly serializes emoji', () => {
expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:');
});
it('correctly serializes headings', () => {
expect(
serialize(
heading({ level: 1 }, 'Heading 1'),
heading({ level: 2 }, 'Heading 2'),
heading({ level: 3 }, 'Heading 3'),
heading({ level: 4 }, 'Heading 4'),
heading({ level: 5 }, 'Heading 5'),
heading({ level: 6 }, 'Heading 6'),
),
).toBe(
`
# Heading 1
## Heading 2
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
`.trim(),
);
});
it('correctly serializes horizontal rule', () => {
expect(serialize(horizontalRule(), horizontalRule(), horizontalRule())).toBe(
`
---
---
---
`.trim(),
);
});
it('correctly serializes an image', () => {
expect(serialize(paragraph(image({ src: 'img.jpg', alt: 'foo bar' })))).toBe(
'![foo bar](img.jpg)',
);
});
it('does not serialize an image when src and canonicalSrc are empty', () => {
expect(serialize(paragraph(image({})))).toBe('');
});
it('correctly serializes an image with a title', () => {
expect(serialize(paragraph(image({ src: 'img.jpg', title: 'baz', alt: 'foo bar' })))).toBe(
'![foo bar](img.jpg "baz")',
);
});
it('correctly serializes an image with a canonicalSrc', () => {
expect(
serialize(
paragraph(
image({
src: '/uploads/abcde/file.png',
alt: 'this is an image',
canonicalSrc: 'file.png',
title: 'foo bar baz',
}),
),
),
).toBe('![this is an image](file.png "foo bar baz")');
});
it('correctly serializes bullet list', () => {
expect(
serialize(
bulletList(
listItem(paragraph('list item 1')),
listItem(paragraph('list item 2')),
listItem(paragraph('list item 3')),
),
),
).toBe(
`
* list item 1
* list item 2
* list item 3
`.trim(),
);
});
it('correctly serializes bullet list with different bullet styles', () => {
expect(
serialize(
bulletList(
{ bullet: '+' },
listItem(paragraph('list item 1')),
listItem(paragraph('list item 2')),
listItem(
paragraph('list item 3'),
bulletList(
{ bullet: '-' },
listItem(paragraph('sub-list item 1')),
listItem(paragraph('sub-list item 2')),
),
),
),
),
).toBe(
`
+ list item 1
+ list item 2
+ list item 3
- sub-list item 1
- sub-list item 2
`.trim(),
);
});
it('correctly serializes a numeric list', () => {
expect(
serialize(
orderedList(
listItem(paragraph('list item 1')),
listItem(paragraph('list item 2')),
listItem(paragraph('list item 3')),
),
),
).toBe(
`
1. list item 1
2. list item 2
3. list item 3
`.trim(),
);
});
it('correctly serializes a numeric list with parens', () => {
expect(
serialize(
orderedList(
{ parens: true },
listItem(paragraph('list item 1')),
listItem(paragraph('list item 2')),
listItem(paragraph('list item 3')),
),
),
).toBe(
`
1) list item 1
2) list item 2
3) list item 3
`.trim(),
);
});
it('correctly serializes a numeric list with a different start order', () => {
expect(
serialize(
orderedList(
{ start: 17 },
listItem(paragraph('list item 1')),
listItem(paragraph('list item 2')),
listItem(paragraph('list item 3')),
),
),
).toBe(
`
17. list item 1
18. list item 2
19. list item 3
`.trim(),
);
});
it('correctly serializes a numeric list with an invalid start order', () => {
expect(
serialize(
orderedList(
{ start: NaN },
listItem(paragraph('list item 1')),
listItem(paragraph('list item 2')),
listItem(paragraph('list item 3')),
),
),
).toBe(
`
1. list item 1
2. list item 2
3. list item 3
`.trim(),
);
});
it('correctly serializes a bullet list inside an ordered list', () => {
expect(
serialize(
orderedList(
{ start: 17 },
listItem(paragraph('list item 1')),
listItem(paragraph('list item 2')),
listItem(
paragraph('list item 3'),
bulletList(
listItem(paragraph('sub-list item 1')),
listItem(paragraph('sub-list item 2')),
),
),
),
),
).toBe(
// notice that 4 space indent works fine in this case,
// when it usually wouldn't
`
17. list item 1
18. list item 2
19. list item 3
* sub-list item 1
* sub-list item 2
`.trim(),
);
});
it('correctly serializes a task list', () => {
expect(
serialize(
taskList(
taskItem({ checked: true }, paragraph('list item 1')),
taskItem(paragraph('list item 2')),
taskItem(
paragraph('list item 3'),
taskList(
taskItem({ checked: true }, paragraph('sub-list item 1')),
taskItem(paragraph('sub-list item 2')),
),
),
),
),
).toBe(
`
* [x] list item 1
* [ ] list item 2
* [ ] list item 3
* [x] sub-list item 1
* [ ] sub-list item 2
`.trim(),
);
});
it('correctly serializes a numeric task list + with start order', () => {
expect(
serialize(
taskList(
{ numeric: true },
taskItem({ checked: true }, paragraph('list item 1')),
taskItem(paragraph('list item 2')),
taskItem(
paragraph('list item 3'),
taskList(
{ numeric: true, start: 1351, parens: true },
taskItem({ checked: true }, paragraph('sub-list item 1')),
taskItem(paragraph('sub-list item 2')),
),
),
),
),
).toBe(
`
1. [x] list item 1
2. [ ] list item 2
3. [ ] list item 3
1351) [x] sub-list item 1
1352) [ ] sub-list item 2
`.trim(),
);
});
it('correctly renders a description list', () => {
expect(
serialize(
descriptionList(
descriptionItem(paragraph('Beast of Bodmin')),
descriptionItem({ isTerm: false }, paragraph('A large feline inhabiting Bodmin Moor.')),
descriptionItem(paragraph('Morgawr')),
descriptionItem({ isTerm: false }, paragraph('A sea serpent.')),
descriptionItem(paragraph('Owlman')),
descriptionItem(
{ isTerm: false },
paragraph('A giant ', italic('owl-like'), ' creature.'),
),
),
heading('this is a heading'),
),
).toBe(
`
Beast of Bodmin
A large feline inhabiting Bodmin Moor.
Morgawr
A sea serpent.
Owlman
A giant _owl-like_ creature.
# this is a heading
`.trim(),
);
});
it('correctly renders a simple details/summary', () => {
expect(
serialize(
details(
detailsContent(paragraph('this is the summary')),
detailsContent(paragraph('this content will be hidden')),
),
heading('this is a heading'),
),
).toBe(
`
this is the summary
this content will be hidden
# this is a heading
`.trim(),
);
});
it('correctly renders details/summary with styled content', () => {
expect(
serialize(
details(
detailsContent(paragraph('this is the ', bold('summary'))),
detailsContent(
codeBlock(
{ language: 'javascript' },
'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);',
),
),
detailsContent(paragraph('this content will be ', italic('hidden'))),
),
details(detailsContent(paragraph('summary 2')), detailsContent(paragraph('content 2'))),
).trim(),
).toBe(
`
this is the **summary**
\`\`\`javascript
var a = 2;
var b = 3;
var c = a + d;
console.log(c);
\`\`\`
this content will be _hidden_
summary 2
content 2
`.trim(),
);
});
it('correctly renders nested details', () => {
expect(
serialize(
details(
detailsContent(paragraph('dream level 1')),
detailsContent(
details(
detailsContent(paragraph('dream level 2')),
detailsContent(
details(
detailsContent(paragraph('dream level 3')),
detailsContent(paragraph(italic('inception'))),
),
),
),
),
),
).trim(),
).toBe(
`
dream level 1dream level 2dream level 3
_inception_
`.trim(),
);
});
it('correctly renders div', () => {
expect(
serialize(
div(paragraph('just a paragraph in a div')),
div(paragraph('just some ', bold('styled'), ' ', italic('content'), ' in a div')),
),
).toBe(
'