Commit 85b73092 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch 'refactor-content-editor-markdown-serializer' into 'master'

Refactor Content Editor Markdown Serializer

See merge request gitlab-org/gitlab!60424
parents 2c1b05c5 2ce19d13
import { Blockquote } from '@tiptap/extension-blockquote';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = Blockquote;
export const serializer = defaultMarkdownSerializer.nodes.blockquote;
import { Bold } from '@tiptap/extension-bold';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = Bold;
export const serializer = defaultMarkdownSerializer.marks.strong;
import { BulletList } from '@tiptap/extension-bullet-list';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = BulletList;
export const serializer = defaultMarkdownSerializer.nodes.bullet_list;
import { Code } from '@tiptap/extension-code';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = Code;
export const serializer = defaultMarkdownSerializer.marks.code;
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
const extractLanguage = (element) => element.firstElementChild?.getAttribute('lang');
export default CodeBlockLowlight.extend({
const ExtendedCodeBlockLowlight = CodeBlockLowlight.extend({
addAttributes() {
return {
...this.parent(),
......@@ -21,3 +22,6 @@ export default CodeBlockLowlight.extend({
};
},
});
export const tiptapExtension = ExtendedCodeBlockLowlight;
export const serializer = defaultMarkdownSerializer.nodes.code_block;
import Document from '@tiptap/extension-document';
export const tiptapExtension = Document;
import Dropcursor from '@tiptap/extension-dropcursor';
export const tiptapExtension = Dropcursor;
import Gapcursor from '@tiptap/extension-gapcursor';
export const tiptapExtension = Gapcursor;
import { HardBreak } from '@tiptap/extension-hard-break';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = HardBreak;
export const serializer = defaultMarkdownSerializer.nodes.hard_break;
import { Heading } from '@tiptap/extension-heading';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = Heading;
export const serializer = defaultMarkdownSerializer.nodes.heading;
import History from '@tiptap/extension-history';
export const tiptapExtension = History;
import { HorizontalRule } from '@tiptap/extension-horizontal-rule';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = HorizontalRule;
export const serializer = defaultMarkdownSerializer.nodes.horizontal_rule;
import { Image } from '@tiptap/extension-image';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
const ExtendedImage = Image.extend({
defaultOptions: { inline: true },
});
export const tiptapExtension = ExtendedImage;
export const serializer = defaultMarkdownSerializer.nodes.image;
import { Italic } from '@tiptap/extension-italic';
export const tiptapExtension = Italic;
export const serializer = { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true };
import { Link } from '@tiptap/extension-link';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = Link;
export const serializer = defaultMarkdownSerializer.marks.link;
import { ListItem } from '@tiptap/extension-list-item';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = ListItem;
export const serializer = defaultMarkdownSerializer.nodes.list_item;
import { OrderedList } from '@tiptap/extension-ordered-list';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = OrderedList;
export const serializer = defaultMarkdownSerializer.nodes.ordered_list;
import { Paragraph } from '@tiptap/extension-paragraph';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = Paragraph;
export const serializer = defaultMarkdownSerializer.nodes.paragraph;
import { Text } from '@tiptap/extension-text';
import { defaultMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
export const tiptapExtension = Text;
export const serializer = defaultMarkdownSerializer.nodes.text;
const buildSerializerConfig = (extensions = []) =>
extensions
.filter(({ serializer }) => serializer)
.reduce(
(serializers, { serializer, tiptapExtension: { name, type } }) => {
const collection = `${type}s`;
return {
...serializers,
[collection]: {
...serializers[collection],
[name]: serializer,
},
};
},
{
nodes: {},
marks: {},
},
);
export default buildSerializerConfig;
import Blockquote from '@tiptap/extension-blockquote';
import Bold from '@tiptap/extension-bold';
import BulletList from '@tiptap/extension-bullet-list';
import Code from '@tiptap/extension-code';
import Document from '@tiptap/extension-document';
import Dropcursor from '@tiptap/extension-dropcursor';
import Gapcursor from '@tiptap/extension-gapcursor';
import HardBreak from '@tiptap/extension-hard-break';
import Heading from '@tiptap/extension-heading';
import History from '@tiptap/extension-history';
import HorizontalRule from '@tiptap/extension-horizontal-rule';
import Image from '@tiptap/extension-image';
import Italic from '@tiptap/extension-italic';
import Link from '@tiptap/extension-link';
import ListItem from '@tiptap/extension-list-item';
import OrderedList from '@tiptap/extension-ordered-list';
import Paragraph from '@tiptap/extension-paragraph';
import Text from '@tiptap/extension-text';
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import CodeBlockHighlight from '../extensions/code_block_highlight';
import * as Blockquote from '../extensions/blockquote';
import * as Bold from '../extensions/bold';
import * as BulletList from '../extensions/bullet_list';
import * as Code from '../extensions/code';
import * as CodeBlockHighlight from '../extensions/code_block_highlight';
import * as Document from '../extensions/document';
import * as Dropcursor from '../extensions/dropcursor';
import * as Gapcursor from '../extensions/gapcursor';
import * as HardBreak from '../extensions/hard_break';
import * as Heading from '../extensions/heading';
import * as History from '../extensions/history';
import * as HorizontalRule from '../extensions/horizontal_rule';
import * as Image from '../extensions/image';
import * as Italic from '../extensions/italic';
import * as Link from '../extensions/link';
import * as ListItem from '../extensions/list_item';
import * as OrderedList from '../extensions/ordered_list';
import * as Paragraph from '../extensions/paragraph';
import * as Text from '../extensions/text';
import buildSerializerConfig from './build_serializer_config';
import { ContentEditor } from './content_editor';
import createMarkdownSerializer from './markdown_serializer';
const createTiptapEditor = ({ extensions = [], options } = {}) =>
const builtInContentEditorExtensions = [
Blockquote,
Bold,
BulletList,
Code,
CodeBlockHighlight,
Document,
Dropcursor,
Gapcursor,
HardBreak,
Heading,
History,
HorizontalRule,
Image,
Italic,
Link,
ListItem,
OrderedList,
Paragraph,
Text,
];
const collectTiptapExtensions = (extensions = []) =>
extensions.map(({ tiptapExtension }) => tiptapExtension);
const createTiptapEditor = ({ extensions = [], ...options } = {}) =>
new Editor({
extensions: [
Dropcursor,
Gapcursor,
History,
Document,
Text,
Paragraph,
Bold,
Italic,
Code,
Link,
Heading,
HardBreak,
Blockquote,
HorizontalRule,
BulletList,
OrderedList,
ListItem,
Image.configure({ inline: true }),
CodeBlockHighlight,
...extensions,
],
extensions: [...extensions],
editorProps: {
attributes: {
class: 'gl-outline-0!',
......@@ -61,8 +65,11 @@ export const createContentEditor = ({ renderMarkdown, extensions = [], tiptapOpt
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
const tiptapEditor = createTiptapEditor({ extensions, options: tiptapOptions });
const serializer = createMarkdownSerializer({ render: renderMarkdown });
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
const tiptapExtensions = collectTiptapExtensions(allExtensions);
const tiptapEditor = createTiptapEditor({ extensions: tiptapExtensions, ...tiptapOptions });
const serializerConfig = buildSerializerConfig(allExtensions);
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
return new ContentEditor({ tiptapEditor, serializer });
};
import {
MarkdownSerializer as ProseMirrorMarkdownSerializer,
defaultMarkdownSerializer,
} from 'prosemirror-markdown/src/to_markdown';
import { MarkdownSerializer as ProseMirrorMarkdownSerializer } from 'prosemirror-markdown/src/to_markdown';
import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model';
const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
......@@ -18,66 +15,46 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
* that parses the Markdown and converts it into HTML.
* @returns a markdown serializer
*/
const create = ({ render = () => null }) => {
return {
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content An arbitrary markdown string
* @returns A ProseMirror JSONDocument
*/
deserialize: async ({ schema, content }) => {
const html = await render(content);
if (!html) {
return null;
}
const parser = new DOMParser();
const {
body: { firstElementChild },
} = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
return state.toJSON();
},
/**
* Converts a ProseMirror JSONDocument based
* on a ProseMirror schema into Markdown
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content A ProseMirror JSONDocument
* @returns A Markdown string
*/
serialize: ({ schema, content }) => {
const document = schema.nodeFromJSON(content);
const { nodes, marks } = defaultMarkdownSerializer;
const serializer = new ProseMirrorMarkdownSerializer(
{
...defaultMarkdownSerializer.nodes,
horizontalRule: nodes.horizontal_rule,
bulletList: nodes.bullet_list,
listItem: nodes.list_item,
orderedList: nodes.ordered_list,
codeBlock: nodes.code_block,
hardBreak: nodes.hard_break,
},
{
...defaultMarkdownSerializer.marks,
bold: marks.strong,
italic: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
},
);
return serializer.serialize(document, {
tightLists: true,
});
},
};
};
export default create;
export default ({ render = () => null, serializerConfig }) => ({
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content An arbitrary markdown string
* @returns A ProseMirror JSONDocument
*/
deserialize: async ({ schema, content }) => {
const html = await render(content);
if (!html) {
return null;
}
const parser = new DOMParser();
const {
body: { firstElementChild },
} = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
return state.toJSON();
},
/**
* Converts a ProseMirror JSONDocument based
* on a ProseMirror schema into Markdown
* @param {ProseMirror.Schema} params.schema A ProseMirror schema that defines
* the types of content supported in the document
* @param {String} params.content A ProseMirror JSONDocument
* @returns A Markdown string
*/
serialize: ({ schema, content }) => {
const proseMirrorDocument = schema.nodeFromJSON(content);
const { nodes, marks } = serializerConfig;
const serializer = new ProseMirrorMarkdownSerializer(nodes, marks);
return serializer.serialize(proseMirrorDocument, {
tightLists: true,
});
},
});
......@@ -16,13 +16,15 @@ describe('content_editor/components/toolbar_button', () => {
toggleFooSpy = jest.fn();
tiptapEditor = createContentEditor({
extensions: [
Extension.create({
addCommands() {
return {
toggleFoo: () => toggleFooSpy,
};
},
}),
{
tiptapExtension: Extension.create({
addCommands() {
return {
toggleFoo: () => toggleFooSpy,
};
},
}),
},
],
renderMarkdown: () => true,
}).tiptapEditor;
......
import * as Blockquote from '~/content_editor/extensions/blockquote';
import * as Bold from '~/content_editor/extensions/bold';
import * as Dropcursor from '~/content_editor/extensions/dropcursor';
import * as Paragraph from '~/content_editor/extensions/paragraph';
import buildSerializerConfig from '~/content_editor/services/build_serializer_config';
describe('content_editor/services/build_serializer_config', () => {
describe('given one or more content editor extensions', () => {
it('creates a serializer config that collects all extension serializers by type', () => {
const extensions = [Bold, Blockquote, Paragraph];
const serializerConfig = buildSerializerConfig(extensions);
extensions.forEach(({ tiptapExtension, serializer }) => {
const { name, type } = tiptapExtension;
expect(serializerConfig[`${type}s`][name]).toBe(serializer);
});
});
});
describe('given an extension without serializer', () => {
it('does not include the extension in the serializer config', () => {
const serializerConfig = buildSerializerConfig([Dropcursor]);
expect(serializerConfig.marks[Dropcursor.tiptapExtension.name]).toBe(undefined);
expect(serializerConfig.nodes[Dropcursor.tiptapExtension.name]).toBe(undefined);
});
});
describe('given no extensions', () => {
it('creates an empty serializer config', () => {
expect(buildSerializerConfig()).toStrictEqual({
marks: {},
nodes: {},
});
});
});
});
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '~/content_editor/constants';
import { createContentEditor } from '~/content_editor/services/create_content_editor';
import { createTestContentEditorExtension } from '../test_utils';
describe('content_editor/services/create_editor', () => {
let renderMarkdown;
......@@ -28,6 +29,22 @@ describe('content_editor/services/create_editor', () => {
expect(renderMarkdown).toHaveBeenCalledWith(serializedContent);
});
it('allows providing external content editor extensions', async () => {
const labelReference = 'this is a ~group::editor';
renderMarkdown.mockReturnValueOnce(
'<p>this is a <span data-reference="label" data-label-name="group::editor">group::editor</span></p>',
);
editor = createContentEditor({
renderMarkdown,
extensions: [createTestContentEditorExtension()],
});
await editor.setSerializedContent(labelReference);
expect(editor.getSerializedContent()).toBe(labelReference);
});
it('throws an error when a renderMarkdown fn is not provided', () => {
expect(() => createContentEditor()).toThrow(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
});
......
import { Node } from '@tiptap/core';
export const createTestContentEditorExtension = () => ({
tiptapExtension: Node.create({
name: 'label',
priority: 101,
inline: true,
group: 'inline',
addAttributes() {
return {
labelName: {
default: null,
parseHTML: (element) => {
return { labelName: element.dataset.labelName };
},
},
};
},
parseHTML() {
return [
{
tag: 'span[data-reference="label"]',
},
];
},
renderHTML({ HTMLAttributes }) {
return ['span', HTMLAttributes, 0];
},
}),
serializer: (state, node) => {
state.write(`~${node.attrs.labelName}`);
state.closeBlock(node);
},
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment