PageRenderTime 54ms CodeModel.GetById 27ms RepoModel.GetById 0ms app.codeStats 0ms

/packages/fabric/editor-bitbucket-transformer/src/serializer.ts

https://bitbucket.org/just-mathur/atlaskit-mk-2
TypeScript | 366 lines | 315 code | 24 blank | 27 comment | 77 complexity | 37ea565cfecfd2f8b0045e74c1ad7331 MD5 | raw file
Possible License(s): LGPL-2.0, Apache-2.0
  1. import {
  2. MarkdownSerializer as PMMarkdownSerializer,
  3. MarkdownSerializerState as PMMarkdownSerializerState,
  4. } from 'prosemirror-markdown';
  5. import { Mark, Node as PMNode } from 'prosemirror-model';
  6. import { escapeMarkdown, stringRepeat } from './util';
  7. import tableNodes from './tableSerializer';
  8. /**
  9. * Look for series of backticks in a string, find length of the longest one, then
  10. * generate a backtick chain of a length longer by one. This is the only proven way
  11. * to escape backticks inside code block and inline code (for python-markdown)
  12. */
  13. const generateOuterBacktickChain: (
  14. text: string,
  15. minLength?: number,
  16. ) => string = (() => {
  17. function getMaxLength(text: String): number {
  18. return (text.match(/`+/g) || []).reduce(
  19. (prev, val) => (val.length > prev.length ? val : prev),
  20. '',
  21. ).length;
  22. }
  23. return function(text: string, minLength = 1): string {
  24. const length = Math.max(minLength, getMaxLength(text) + 1);
  25. return stringRepeat('`', length);
  26. };
  27. })();
  28. export class MarkdownSerializerState extends PMMarkdownSerializerState {
  29. renderContent(parent: PMNode): void {
  30. parent.forEach((child: PMNode, offset: number, index: number) => {
  31. if (
  32. // If child is an empty Textblock we need to insert a zwnj-character in order to preserve that line in markdown
  33. child.isTextblock &&
  34. !child.textContent &&
  35. // If child is a Codeblock we need to handle this separately as we want to preserve empty code blocks
  36. !(child.type.name === 'codeBlock') &&
  37. !(child.content && (child.content as any).size > 0)
  38. ) {
  39. return nodes.empty_line(this, child);
  40. }
  41. return this.render(child, parent, index);
  42. });
  43. }
  44. /**
  45. * This method override will properly escape backticks in text nodes with "code" mark enabled.
  46. * Bitbucket uses python-markdown which does not honor escaped backtick escape sequences \`
  47. * inside a backtick fence.
  48. *
  49. * @see node_modules/prosemirror-markdown/src/to_markdown.js
  50. * @see MarkdownSerializerState.renderInline()
  51. */
  52. renderInline(parent: PMNode): void {
  53. const active: Mark[] = [];
  54. const progress = (node: PMNode | null, _?: any, index?: number) => {
  55. let marks = node
  56. ? node.marks.filter(mark => this.marks[mark.type.name])
  57. : [];
  58. const code =
  59. marks.length &&
  60. marks[marks.length - 1].type.name === 'code' &&
  61. marks[marks.length - 1];
  62. const len = marks.length - (code ? 1 : 0);
  63. // Try to reorder 'mixable' marks, such as em and strong, which
  64. // in Markdown may be opened and closed in different order, so
  65. // that order of the marks for the token matches the order in
  66. // active.
  67. outer: for (let i = 0; i < len; i++) {
  68. const mark: Mark = marks[i];
  69. if (!this.marks[mark.type.name].mixable) {
  70. break;
  71. }
  72. for (let j = 0; j < active.length; j++) {
  73. const other = active[j];
  74. if (!this.marks[other.type.name].mixable) {
  75. break;
  76. }
  77. if (mark.eq(other)) {
  78. if (i > j) {
  79. marks = marks
  80. .slice(0, j)
  81. .concat(mark)
  82. .concat(marks.slice(j, i))
  83. .concat(marks.slice(i + 1, len));
  84. } else if (j > i) {
  85. marks = marks
  86. .slice(0, i)
  87. .concat(marks.slice(i + 1, j))
  88. .concat(mark)
  89. .concat(marks.slice(j, len));
  90. }
  91. continue outer;
  92. }
  93. }
  94. }
  95. // Find the prefix of the mark set that didn't change
  96. let keep = 0;
  97. while (
  98. keep < Math.min(active.length, len) &&
  99. marks[keep].eq(active[keep])
  100. ) {
  101. ++keep;
  102. }
  103. // Close the marks that need to be closed
  104. while (keep < active.length) {
  105. this.text(this.markString(active.pop()!, false), false);
  106. }
  107. // Open the marks that need to be opened
  108. while (active.length < len) {
  109. const add = marks[active.length];
  110. active.push(add);
  111. this.text(this.markString(add, true), false);
  112. }
  113. if (node) {
  114. if (!code || !node.isText) {
  115. this.render(node, parent, index!);
  116. } else if (node.text) {
  117. // Generate valid monospace, fenced with series of backticks longer that backtick series inside it.
  118. let text = node.text;
  119. const backticks = generateOuterBacktickChain(node.text as string, 1);
  120. // Make sure there is a space between fences, otherwise python-markdown renderer will get confused
  121. if (text.match(/^`/)) {
  122. text = ' ' + text;
  123. }
  124. if (text.match(/`$/)) {
  125. text += ' ';
  126. }
  127. this.text(backticks + text + backticks, false);
  128. }
  129. }
  130. };
  131. parent.forEach((child: PMNode, offset: number, index: number) => {
  132. progress(child, parent, index);
  133. });
  134. progress(null);
  135. }
  136. }
  137. export class MarkdownSerializer extends PMMarkdownSerializer {
  138. serialize(content: PMNode, options?: { [key: string]: any }): string {
  139. const state = new MarkdownSerializerState(
  140. this.nodes,
  141. this.marks,
  142. options || {},
  143. );
  144. state.renderContent(content);
  145. return state.out === '\u200c' ? '' : state.out; // Return empty string if editor only contains a zero-non-width character
  146. }
  147. }
  148. const editorNodes = {
  149. blockquote(
  150. state: MarkdownSerializerState,
  151. node: PMNode,
  152. parent: PMNode,
  153. index: number,
  154. ) {
  155. state.wrapBlock('> ', undefined, node, () => state.renderContent(node));
  156. },
  157. codeBlock(
  158. state: MarkdownSerializerState,
  159. node: PMNode,
  160. parent: PMNode,
  161. index: number,
  162. ) {
  163. if (!node.attrs.language) {
  164. state.wrapBlock(' ', undefined, node, () =>
  165. state.text(node.textContent ? node.textContent : '\u200c', false),
  166. );
  167. } else {
  168. const backticks = generateOuterBacktickChain(node.textContent, 3);
  169. state.write(backticks + node.attrs.language + '\n');
  170. state.text(node.textContent ? node.textContent : '\u200c', false);
  171. state.ensureNewLine();
  172. state.write(backticks);
  173. }
  174. state.closeBlock(node);
  175. },
  176. heading(
  177. state: MarkdownSerializerState,
  178. node: PMNode,
  179. parent: PMNode,
  180. index: number,
  181. ) {
  182. state.write(state.repeat('#', node.attrs.level) + ' ');
  183. state.renderInline(node);
  184. state.closeBlock(node);
  185. },
  186. rule(state: MarkdownSerializerState, node: PMNode) {
  187. state.write(node.attrs.markup || '---');
  188. state.closeBlock(node);
  189. },
  190. bulletList(
  191. state: MarkdownSerializerState,
  192. node: PMNode,
  193. parent: PMNode,
  194. index: number,
  195. ) {
  196. for (let i = 0; i < node.childCount; i++) {
  197. const child = node.child(i);
  198. state.render(child, node, i);
  199. }
  200. },
  201. orderedList(
  202. state: MarkdownSerializerState,
  203. node: PMNode,
  204. parent: PMNode,
  205. index: number,
  206. ) {
  207. for (let i = 0; i < node.childCount; i++) {
  208. const child = node.child(i);
  209. state.render(child, node, i);
  210. }
  211. },
  212. listItem(
  213. state: MarkdownSerializerState,
  214. node: PMNode,
  215. parent: PMNode,
  216. index: number,
  217. ) {
  218. const delimiter =
  219. parent.type.name === 'bulletList' ? '* ' : `${index + 1}. `;
  220. for (let i = 0; i < node.childCount; i++) {
  221. const child = node.child(i);
  222. if (i > 0) {
  223. state.write('\n');
  224. }
  225. if (i === 0) {
  226. state.wrapBlock(' ', delimiter, node, () =>
  227. state.render(child, parent, i),
  228. );
  229. } else {
  230. state.wrapBlock(' ', undefined, node, () =>
  231. state.render(child, parent, i),
  232. );
  233. }
  234. if (child.type.name === 'paragraph' && i > 0) {
  235. state.write('\n');
  236. }
  237. state.flushClose(1);
  238. }
  239. if (index === parent.childCount - 1) {
  240. state.write('\n');
  241. }
  242. },
  243. paragraph(
  244. state: MarkdownSerializerState,
  245. node: PMNode,
  246. parent: PMNode,
  247. index: number,
  248. ) {
  249. state.renderInline(node);
  250. state.closeBlock(node);
  251. },
  252. image(state: MarkdownSerializerState, node: PMNode) {
  253. // Note: the 'title' is not escaped in this flavor of markdown.
  254. state.write(
  255. '![' +
  256. escapeMarkdown(node.attrs.alt) +
  257. '](' +
  258. node.attrs.src +
  259. (node.attrs.title ? ` '${escapeMarkdown(node.attrs.title)}'` : '') +
  260. ')',
  261. );
  262. },
  263. hardBreak(state: MarkdownSerializerState) {
  264. state.write(' \n');
  265. },
  266. text(
  267. state: MarkdownSerializerState,
  268. node: PMNode,
  269. parent: PMNode,
  270. index: number,
  271. ) {
  272. const previousNode = index === 0 ? null : parent.child(index - 1);
  273. const previousNodeIsAMention =
  274. previousNode && previousNode.type.name === 'mention';
  275. const currentNodeStartWithASpace = node.textContent.indexOf(' ') === 0;
  276. const trimTrailingWhitespace =
  277. previousNodeIsAMention && currentNodeStartWithASpace;
  278. let text = trimTrailingWhitespace
  279. ? node.textContent.replace(' ', '') // only first blank space occurrence is replaced
  280. : node.textContent;
  281. // BB converts 4 spaces at the beginning of the line to code block
  282. // that's why we escape 4 spaces with zero-width-non-joiner
  283. const fourSpaces = ' ';
  284. if (!previousNode && /^\s{4}/.test(node.textContent)) {
  285. text = node.textContent.replace(fourSpaces, '\u200c' + fourSpaces);
  286. }
  287. const lines = text.split('\n');
  288. for (let i = 0; i < lines.length; i++) {
  289. const startOfLine = state.atBlank() || !!state.closed;
  290. state.write();
  291. state.out += escapeMarkdown(lines[i], startOfLine);
  292. if (i !== lines.length - 1) {
  293. if (
  294. lines[i] &&
  295. lines[i].length &&
  296. lines[i + 1] &&
  297. lines[i + 1].length
  298. ) {
  299. state.out += ' ';
  300. }
  301. state.out += '\n';
  302. }
  303. }
  304. },
  305. empty_line(state: MarkdownSerializerState, node: PMNode) {
  306. state.write('\u200c'); // zero-width-non-joiner
  307. state.closeBlock(node);
  308. },
  309. mention(
  310. state: MarkdownSerializerState,
  311. node: PMNode,
  312. parent: PMNode,
  313. index: number,
  314. ) {
  315. const isLastNode = parent.childCount === index + 1;
  316. const delimiter = isLastNode ? '' : ' ';
  317. state.write(`@${node.attrs.id}${delimiter}`);
  318. },
  319. emoji(
  320. state: MarkdownSerializerState,
  321. node: PMNode,
  322. parent: PMNode,
  323. index: number,
  324. ) {
  325. state.write(node.attrs.shortName);
  326. },
  327. };
  328. export const nodes = { ...editorNodes, ...tableNodes };
  329. export const marks = {
  330. em: { open: '*', close: '*', mixable: true },
  331. strong: { open: '**', close: '**', mixable: true },
  332. strike: { open: '~~', close: '~~', mixable: true },
  333. link: {
  334. open: '[',
  335. close(state: MarkdownSerializerState, mark: any) {
  336. return '](' + mark.attrs['href'] + ')';
  337. },
  338. },
  339. code: { open: '`', close: '`' },
  340. mentionQuery: { open: '', close: '', mixable: false },
  341. emojiQuery: { open: '', close: '', mixable: false },
  342. };