/packages/fabric/editor-bitbucket-transformer/src/serializer.ts
TypeScript | 366 lines | 315 code | 24 blank | 27 comment | 77 complexity | 37ea565cfecfd2f8b0045e74c1ad7331 MD5 | raw file
Possible License(s): LGPL-2.0, Apache-2.0
- import {
- MarkdownSerializer as PMMarkdownSerializer,
- MarkdownSerializerState as PMMarkdownSerializerState,
- } from 'prosemirror-markdown';
- import { Mark, Node as PMNode } from 'prosemirror-model';
- import { escapeMarkdown, stringRepeat } from './util';
- 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 separately as we want to preserve empty code blocks
- !(child.type.name === '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.name === '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('> ', undefined, node, () => state.renderContent(node));
- },
- codeBlock(
- state: MarkdownSerializerState,
- node: PMNode,
- parent: PMNode,
- index: number,
- ) {
- if (!node.attrs.language) {
- state.wrapBlock(' ', undefined, 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(' ', undefined, 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) +
- '](' +
- 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.name === '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) {
- if (
- lines[i] &&
- lines[i].length &&
- lines[i + 1] &&
- lines[i + 1].length
- ) {
- state.out += ' ';
- }
- 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) {
- return '](' + mark.attrs['href'] + ')';
- },
- },
- code: { open: '`', close: '`' },
- mentionQuery: { open: '', close: '', mixable: false },
- emojiQuery: { open: '', close: '', mixable: false },
- };