UNPKG

@atlaskit/editor-core

Version:

A package contains Atlassian editor core functionality

276 lines (251 loc) • 10.4 kB
import { Mark, MarkdownSerializer as PMMarkdownSerializer, MarkdownSerializerState as PMMarkdownSerializerState, Node as PMNode, } from '../../prosemirror'; import { escapeMarkdown, stringRepeat, } from './util'; import { bitbucketSchema as schema } from '../../schema'; import tableNodes from './tableSerializer'; /** * Look for series of backticks in a string, find length of the longest one, then * generate a backtick chain of a length longer by one. This is the only proven way * to escape backticks inside code block and inline code (for python-markdown) */ const generateOuterBacktickChain: (text: string, minLength?: number) => string = (() => { function getMaxLength(text: String): number { return (text.match(/`+/g) || []) .reduce((prev, val) => (val.length > prev.length ? val : prev), '') .length ; } return function (text: string, minLength = 1): string { const length = Math.max(minLength, getMaxLength(text) + 1); return stringRepeat('`', length); }; })(); export class MarkdownSerializerState extends PMMarkdownSerializerState { renderContent(parent: PMNode): void { parent.forEach((child: PMNode, offset: number, index: number) => { if ( // If child is an empty Textblock we need to insert a zwnj-character in order to preserve that line in markdown (child.isTextblock && !child.textContent) && // If child is a Codeblock we need to handle this seperately as we want to preserve empty code blocks !(child.type === schema.nodes.codeBlock) && !(child.content && (child.content as any).size > 0) ) { return nodes.empty_line(this, child); } return this.render(child, parent, index); }); } /** * This method override will properly escape backticks in text nodes with "code" mark enabled. * Bitbucket uses python-markdown which does not honor escaped backtick escape sequences \` * inside a backtick fence. * * @see node_modules/prosemirror-markdown/src/to_markdown.js * @see MarkdownSerializerState.renderInline() */ renderInline(parent: PMNode): void { const active: Mark[] = []; const progress = (node: PMNode | null, _?: any, index?: number) => { let marks = node ? node.marks.filter(mark => this.marks[mark.type.name]) : []; const code = marks.length && marks[marks.length - 1].type === schema.marks.code && marks[marks.length - 1]; const len = marks.length - (code ? 1 : 0); // Try to reorder 'mixable' marks, such as em and strong, which // in Markdown may be opened and closed in different order, so // that order of the marks for the token matches the order in // active. outer: for (let i = 0; i < len; i++) { const mark: Mark = marks[i]; if (!this.marks[mark.type.name].mixable) { break; } for (let j = 0; j < active.length; j++) { const other = active[j]; if (!this.marks[other.type.name].mixable) { break; } if (mark.eq(other)) { if (i > j) { marks = marks.slice(0, j).concat(mark).concat(marks.slice(j, i)).concat(marks.slice(i + 1, len)); } else if (j > i) { marks = marks.slice(0, i).concat(marks.slice(i + 1, j)).concat(mark).concat(marks.slice(j, len)); } continue outer; } } } // Find the prefix of the mark set that didn't change let keep = 0; while (keep < Math.min(active.length, len) && marks[keep].eq(active[keep])) { ++keep; } // Close the marks that need to be closed while (keep < active.length) { this.text(this.markString(active.pop()!, false), false); } // Open the marks that need to be opened while (active.length < len) { const add = marks[active.length]; active.push(add); this.text(this.markString(add, true), false); } if (node) { if (!code || !node.isText) { this.render(node, parent, index!); } else if (node.text) { // Generate valid monospace, fenced with series of backticks longer that backtick series inside it. let text = node.text; const backticks = generateOuterBacktickChain(node.text as string, 1); // Make sure there is a space between fences, otherwise python-markdown renderer will get confused if (text.match(/^`/)) { text = ' ' + text; } if (text.match(/`$/)) { text += ' '; } this.text(backticks + text + backticks, false); } } }; parent.forEach((child: PMNode, offset: number, index: number) => { progress(child, parent, index); }); progress(null); } } export class MarkdownSerializer extends PMMarkdownSerializer { serialize(content: PMNode, options?: { [key: string]: any }): string { const state = new MarkdownSerializerState(this.nodes, this.marks, options); state.renderContent(content); return state.out === '\u200c' ? '' : state.out; // Return empty string if editor only contains a zero-non-width character } } const editorNodes = { blockquote(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { state.wrapBlock('> ', null, node, () => state.renderContent(node)); }, codeBlock(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { if (!node.attrs.language) { state.wrapBlock(' ', null, node, () => state.text(node.textContent ? node.textContent : '\u200c', false)); } else { const backticks = generateOuterBacktickChain(node.textContent, 3); state.write(backticks + node.attrs.language + '\n'); state.text(node.textContent ? node.textContent : '\u200c', false); state.ensureNewLine(); state.write(backticks); } state.closeBlock(node); }, heading(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { state.write(state.repeat('#', node.attrs.level) + ' '); state.renderInline(node); state.closeBlock(node); }, rule(state: MarkdownSerializerState, node: PMNode) { state.write(node.attrs.markup || '---'); state.closeBlock(node); }, bulletList(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { for (let i = 0; i < node.childCount; i++) { const child = node.child(i); state.render(child, node, i); } }, orderedList(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { for (let i = 0; i < node.childCount; i++) { const child = node.child(i); state.render(child, node, i); } }, listItem(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { const delimiter = parent.type.name === 'bulletList' ? '* ' : `${index + 1}. `; for (let i = 0; i < node.childCount; i++) { const child = node.child(i); if (i > 0) { state.write('\n'); } if (i === 0) { state.wrapBlock(' ', delimiter, node, () => state.render(child, parent, i)); } else { state.wrapBlock(' ', null, node, () => state.render(child, parent, i)); } if (child.type.name === 'paragraph' && i > 0) { state.write('\n'); } state.flushClose(1); } if (index === parent.childCount - 1) { state.write('\n'); } }, paragraph(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { state.renderInline(node); state.closeBlock(node); }, image(state: MarkdownSerializerState, node: PMNode) { // Note: the 'title' is not escaped in this flavor of markdown. state.write('![' + escapeMarkdown(node.attrs.alt) + '](' + escapeMarkdown(node.attrs.src) + (node.attrs.title ? ` '${escapeMarkdown(node.attrs.title)}'` : '') + ')'); }, hardBreak(state: MarkdownSerializerState) { state.write(' \n'); }, text(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { const previousNode = index === 0 ? null : parent.child(index - 1); const previousNodeIsAMention = (previousNode && previousNode.type === schema.nodes.mention); const currentNodeStartWithASpace = (node.textContent.indexOf(' ') === 0); const trimTrailingWhitespace = (previousNodeIsAMention && currentNodeStartWithASpace); let text = trimTrailingWhitespace ? node.textContent.replace(' ', '') // only first blank space occurrence is replaced : node.textContent; // BB converts 4 spaces at the beginning of the line to code block // that's why we escape 4 spaces with zero-width-non-joiner const fourSpaces = ' '; if (!previousNode && /^\s{4}/.test(node.textContent)) { text = node.textContent.replace(fourSpaces, '\u200c' + fourSpaces); } const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { const startOfLine = state.atBlank() || !!state.closed; state.write(); state.out += escapeMarkdown(lines[i], startOfLine); if (i !== lines.length - 1) { state.out += '\n'; } } }, empty_line(state: MarkdownSerializerState, node: PMNode) { state.write('\u200c'); // zero-width-non-joiner state.closeBlock(node); }, mention(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { const isLastNode = (parent.childCount === index + 1); const delimiter = isLastNode ? '' : ' '; state.write(`@${node.attrs.id}${delimiter}`); }, emoji(state: MarkdownSerializerState, node: PMNode, parent: PMNode, index: number) { state.write(node.attrs.shortName); } }; export const nodes = { ...editorNodes, ...tableNodes }; export const marks = { em: { open: '*', close: '*', mixable: true }, strong: { open: '**', close: '**', mixable: true }, strike: { open: '~~', close: '~~', mixable: true }, link: { open: '[', close(state: MarkdownSerializerState, mark: any) { // Note: the 'title' is not escaped in this flavor of markdown. return '](' + mark.attrs['href'] + (mark.attrs['title'] ? ` '${mark.attrs['title']}'` : '') + ')'; } }, code: { open: '`', close: '`' }, mentionQuery: { open: '', close: '', mixable: false }, emojiQuery: { open: '', close: '', mixable: false } };