summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDouwe Maan <douwe@selenight.nl>2019-01-23 12:21:12 +0100
committerDouwe Maan <douwe@selenight.nl>2019-01-24 12:06:53 +0100
commit8a03dbf8b7e6776264cef9824aba1e58dcbaef70 (patch)
tree07612ef69e0c6404bcbf382225e23b67ca5c2cff
parenta7e77e10fec4c37782edf09aafa12ae0c2d870df (diff)
downloadgitlab-ce-db-copy-as-gfm-prosemirror.tar.gz
Use nodes and marks to power Copy-as-GFMdb-copy-as-gfm-prosemirror
The spec needed to be updated because in some cases the resulting Markdown is slightly different, though equally valid.
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js400
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js106
-rw-r--r--app/assets/javascripts/behaviors/markdown/schema.js24
-rw-r--r--app/assets/javascripts/behaviors/markdown/serializer.js24
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js11
-rw-r--r--lib/banzai/filter/emoji_filter.rb1
-rw-r--r--lib/banzai/filter/image_lazy_load_filter.rb1
-rw-r--r--lib/banzai/filter/image_link_filter.rb1
-rw-r--r--lib/banzai/filter/inline_diff_filter.rb1
-rw-r--r--lib/banzai/filter/math_filter.rb3
-rw-r--r--lib/banzai/filter/mermaid_filter.rb1
-rw-r--r--lib/banzai/filter/reference_filter.rb1
-rw-r--r--lib/banzai/filter/syntax_highlight_filter.rb1
-rw-r--r--lib/banzai/filter/table_of_contents_filter.rb1
-rw-r--r--lib/banzai/filter/task_list_filter.rb4
-rw-r--r--lib/banzai/filter/video_link_filter.rb1
-rw-r--r--lib/banzai/pipeline/gfm_pipeline.rb10
-rw-r--r--spec/features/markdown/copy_as_gfm_spec.rb184
-rw-r--r--spec/javascripts/behaviors/copy_as_gfm_spec.js4
19 files changed, 306 insertions, 473 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index fe02096d903..947d019c725 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -1,320 +1,8 @@
-/* eslint-disable object-shorthand, no-unused-vars, no-use-before-define, no-restricted-syntax, guard-for-in, no-continue */
-
import $ from 'jquery';
-import _ from 'underscore';
-import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils';
-import { placeholderImage } from '~/lazy_loader';
-
-const gfmRules = {
- // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
- // GitLab Flavored Markdown (GFM) to HTML.
- // These handlers consequently convert that same HTML to GFM to be copied to the clipboard.
- // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
- // from GFM should have a handler here, in reverse order.
- // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
- InlineDiffFilter: {
- 'span.idiff.addition'(el, text) {
- return `{+${text}+}`;
- },
- 'span.idiff.deletion'(el, text) {
- return `{-${text}-}`;
- },
- },
- TaskListFilter: {
- 'input[type=checkbox].task-list-item-checkbox'(el) {
- return `[${el.checked ? 'x' : ' '}]`;
- },
- },
- ReferenceFilter: {
- '.tooltip'(el) {
- return '';
- },
- 'a.gfm:not([data-link=true])'(el, text) {
- return el.dataset.original || text;
- },
- },
- AutolinkFilter: {
- a(el, text) {
- // Fallback on the regular MarkdownFilter's `a` handler.
- if (text !== el.getAttribute('href')) return false;
-
- return text;
- },
- },
- TableOfContentsFilter: {
- 'ul.section-nav'(el) {
- return '[[_TOC_]]';
- },
- },
- EmojiFilter: {
- 'img.emoji'(el) {
- return el.getAttribute('alt');
- },
- 'gl-emoji'(el) {
- return `:${el.getAttribute('data-name')}:`;
- },
- },
- ImageLinkFilter: {
- 'a.no-attachment-icon'(el, text) {
- return text;
- },
- },
- ImageLazyLoadFilter: {
- img(el, text) {
- return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
- },
- },
- VideoLinkFilter: {
- '.video-container'(el) {
- const videoEl = el.querySelector('video');
- if (!videoEl) return false;
-
- return CopyAsGFM.nodeToGFM(videoEl);
- },
- video(el) {
- return `![${el.dataset.title}](${el.getAttribute('src')})`;
- },
- },
- MermaidFilter: {
- 'svg.mermaid'(el, text) {
- const sourceEl = el.querySelector('text.source');
- if (!sourceEl) return false;
-
- return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``;
- },
- 'svg.mermaid style, svg.mermaid g'(el, text) {
- // We don't want to include the content of these elements in the copied text.
- return '';
- },
- },
- MathFilter: {
- 'pre.code.math[data-math-style=display]'(el, text) {
- return `\`\`\`math\n${text.trim()}\n\`\`\``;
- },
- 'code.code.math[data-math-style=inline]'(el, text) {
- return `$\`${text}\`$`;
- },
- 'span.katex-display span.katex-mathml'(el) {
- const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
- if (!mathAnnotation) return false;
-
- return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
- },
- 'span.katex-mathml'(el) {
- const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
- if (!mathAnnotation) return false;
-
- return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
- },
- 'span.katex-html'(el) {
- // We don't want to include the content of this element in the copied text.
- return '';
- },
- 'annotation[encoding="application/x-tex"]'(el, text) {
- return text.trim();
- },
- },
- SanitizationFilter: {
- 'a[name]:not([href]):empty'(el) {
- return el.outerHTML;
- },
- dl(el, text) {
- let lines = text
- .replace(/\n\n/g, '\n')
- .trim()
- .split('\n');
- // Add two spaces to the front of subsequent list items lines,
- // or leave the line entirely blank.
- lines = lines.map(l => {
- const line = l.trim();
- if (line.length === 0) return '';
-
- return ` ${line}`;
- });
-
- return `<dl>\n${lines.join('\n')}\n</dl>\n`;
- },
- 'dt, dd, summary, details'(el, text) {
- const tag = el.nodeName.toLowerCase();
- return `<${tag}>${text}</${tag}>\n`;
- },
- 'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) {
- const tag = el.nodeName.toLowerCase();
- return `<${tag}>${text}</${tag}>`;
- },
- },
- SyntaxHighlightFilter: {
- 'pre.code.highlight'(el, t) {
- const text = t.trimRight();
-
- let lang = el.getAttribute('lang');
- if (!lang || lang === 'plaintext') {
- lang = '';
- }
-
- // Prefixes lines with 4 spaces if the code contains triple backticks
- if (lang === '' && text.match(/^```/gm)) {
- return text
- .split('\n')
- .map(l => {
- const line = l.trim();
- if (line.length === 0) return '';
-
- return ` ${line}`;
- })
- .join('\n');
- }
-
- return `\`\`\`${lang}\n${text}\n\`\`\``;
- },
- 'pre > code'(el, text) {
- // Don't wrap code blocks in ``
- return text;
- },
- },
- MarkdownFilter: {
- br(el) {
- // Two spaces at the end of a line are turned into a BR
- return ' ';
- },
- code(el, text) {
- let backtickCount = 1;
- const backtickMatch = text.match(/`+/);
- if (backtickMatch) {
- backtickCount = backtickMatch[0].length + 1;
- }
-
- const backticks = Array(backtickCount + 1).join('`');
- const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
-
- return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
- },
- blockquote(el, text) {
- return text
- .trim()
- .split('\n')
- .map(s => `> ${s}`.trim())
- .join('\n');
- },
- img(el) {
- const imageSrc = el.src;
- const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
- return `![${el.getAttribute('alt')}](${imageUrl})`;
- },
- 'a.anchor'(el, text) {
- // Don't render a Markdown link for the anchor link inside a heading
- return text;
- },
- a(el, text) {
- return `[${text}](${el.getAttribute('href')})`;
- },
- li(el, text) {
- const lines = text.trim().split('\n');
- const firstLine = `- ${lines.shift()}`;
- // Add four spaces to the front of subsequent list items lines,
- // or leave the line entirely blank.
- const nextLines = lines.map(s => {
- if (s.trim().length === 0) return '';
-
- return ` ${s}`;
- });
-
- return `${firstLine}\n${nextLines.join('\n')}`;
- },
- ul(el, text) {
- return text;
- },
- ol(el, text) {
- // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists.
- return text.replace(/^- /gm, '1. ');
- },
- h1(el, text) {
- return `# ${text.trim()}\n`;
- },
- h2(el, text) {
- return `## ${text.trim()}\n`;
- },
- h3(el, text) {
- return `### ${text.trim()}\n`;
- },
- h4(el, text) {
- return `#### ${text.trim()}\n`;
- },
- h5(el, text) {
- return `##### ${text.trim()}\n`;
- },
- h6(el, text) {
- return `###### ${text.trim()}\n`;
- },
- strong(el, text) {
- return `**${text}**`;
- },
- em(el, text) {
- return `_${text}_`;
- },
- del(el, text) {
- return `~~${text}~~`;
- },
- hr(el) {
- // extra leading \n is to ensure that there is a blank line between
- // a list followed by an hr, otherwise this breaks old redcarpet rendering
- return '\n-----\n';
- },
- p(el, text) {
- return `${text.trim()}\n`;
- },
- table(el) {
- const theadEl = el.querySelector('thead');
- const tbodyEl = el.querySelector('tbody');
- if (!theadEl || !tbodyEl) return false;
-
- const theadText = CopyAsGFM.nodeToGFM(theadEl);
- const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
-
- return [theadText, tbodyText].join('\n');
- },
- thead(el, text) {
- const cells = _.map(el.querySelectorAll('th'), cell => {
- let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
-
- let before = '';
- let after = '';
- const alignment = cell.align || cell.style.textAlign;
-
- switch (alignment) {
- case 'center':
- before = ':';
- after = ':';
- chars -= 2;
- break;
- case 'right':
- after = ':';
- chars -= 1;
- break;
- default:
- break;
- }
-
- chars = Math.max(chars, 3);
-
- const middle = Array(chars + 1).join('-');
-
- return before + middle + after;
- });
-
- const separatorRow = `|${cells.join('|')}|`;
-
- return [text, separatorRow].join('\n');
- },
- tr(el) {
- const cellEls = el.querySelectorAll('td, th');
- if (cellEls.length === 0) return false;
-
- const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
- return `| ${cells.join(' | ')} |`;
- },
- },
-};
+import { DOMParser } from 'prosemirror-model';
+import { getSelectedFragment } from '~/lib/utils/common_utils';
+import schema from './schema';
+import markdownSerializer from './serializer';
export class CopyAsGFM {
constructor() {
@@ -347,8 +35,13 @@ export class CopyAsGFM {
e.preventDefault();
e.stopPropagation();
+ const div = document.createElement('div');
+ div.appendChild(el.cloneNode(true));
+ const html = div.innerHTML;
+
clipboardData.setData('text/plain', el.textContent);
clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
+ clipboardData.setData('text/html', html);
}
static pasteGFM(e) {
@@ -361,7 +54,7 @@ export class CopyAsGFM {
e.preventDefault();
- window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
+ window.gl.utils.insertText(e.target, textBefore => {
// If the text before the cursor contains an odd number of backticks,
// we are either inside an inline code span that starts with 1 backtick
// or a code block that starts with 3 backticks.
@@ -443,75 +136,12 @@ export class CopyAsGFM {
return codeElement;
}
- static nodeToGFM(node, respectWhitespaceParam = false) {
- if (node.nodeType === Node.COMMENT_NODE) {
- return '';
- }
-
- if (node.nodeType === Node.TEXT_NODE) {
- return node.textContent;
- }
-
- const respectWhitespace =
- respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
-
- const text = this.innerGFM(node, respectWhitespace);
-
- if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
- return text;
- }
-
- for (const filter in gfmRules) {
- const rules = gfmRules[filter];
-
- for (const selector in rules) {
- const func = rules[selector];
-
- if (!nodeMatchesSelector(node, selector)) continue;
-
- let result;
- if (func.length === 2) {
- // if `func` takes 2 arguments, it depends on text.
- // if there is no text, we don't need to generate GFM for this node.
- if (text.length === 0) continue;
-
- result = func(node, text);
- } else {
- result = func(node);
- }
-
- if (result === false) continue;
-
- return result;
- }
- }
-
- return text;
- }
-
- static innerGFM(parentNode, respectWhitespace = false) {
- const nodes = parentNode.childNodes;
-
- const clonedParentNode = parentNode.cloneNode(true);
- const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
-
- for (let i = 0; i < nodes.length; i += 1) {
- const node = nodes[i];
- const clonedNode = clonedNodes[i];
-
- const text = this.nodeToGFM(node, respectWhitespace);
-
- // `clonedNode.replaceWith(text)` is not yet widely supported
- clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
- }
-
- let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
-
- if (!respectWhitespace) {
- nodeText = nodeText.trim();
- }
+ static nodeToGFM(node) {
+ const wrapEl = document.createElement('div');
+ wrapEl.appendChild(node.cloneNode(true));
+ const doc = DOMParser.fromSchema(schema).parse(wrapEl);
- return nodeText;
+ return markdownSerializer.serialize(doc);
}
}
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
new file mode 100644
index 00000000000..47e5fc65c48
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -0,0 +1,106 @@
+import Doc from './nodes/doc';
+import Paragraph from './nodes/paragraph';
+import Text from './nodes/text';
+
+import Blockquote from './nodes/blockquote';
+import CodeBlock from './nodes/code_block';
+import HardBreak from './nodes/hard_break';
+import Heading from './nodes/heading';
+import HorizontalRule from './nodes/horizontal_rule';
+import Image from './nodes/image';
+
+import Table from './nodes/table';
+import TableHead from './nodes/table_head';
+import TableBody from './nodes/table_body';
+import TableHeaderRow from './nodes/table_header_row';
+import TableRow from './nodes/table_row';
+import TableCell from './nodes/table_cell';
+
+import Emoji from './nodes/emoji';
+import Reference from './nodes/reference';
+
+import TableOfContents from './nodes/table_of_contents';
+import Video from './nodes/video';
+
+import BulletList from './nodes/bullet_list';
+import OrderedList from './nodes/ordered_list';
+import ListItem from './nodes/list_item';
+
+import DescriptionList from './nodes/description_list';
+import DescriptionTerm from './nodes/description_term';
+import DescriptionDetails from './nodes/description_details';
+
+import TaskList from './nodes/task_list';
+import OrderedTaskList from './nodes/ordered_task_list';
+import TaskListItem from './nodes/task_list_item';
+
+import Summary from './nodes/summary';
+import Details from './nodes/details';
+
+import Bold from './marks/bold';
+import Italic from './marks/italic';
+import Strike from './marks/strike';
+import InlineDiff from './marks/inline_diff';
+
+import Link from './marks/link';
+import Code from './marks/code';
+import MathMark from './marks/math';
+import InlineHTML from './marks/inline_html';
+
+// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform
+// GitLab Flavored Markdown (GFM) to HTML.
+// The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard.
+// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
+// from GFM should have a node or mark here.
+// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
+
+export default [
+ new Doc(),
+ new Paragraph(),
+ new Text(),
+
+ new Blockquote(),
+ new CodeBlock(),
+ new HardBreak(),
+ new Heading({ maxLevel: 6 }),
+ new HorizontalRule(),
+ new Image(),
+
+ new Table(),
+ new TableHead(),
+ new TableBody(),
+ new TableHeaderRow(),
+ new TableRow(),
+ new TableCell(),
+
+ new Emoji(),
+ new Reference(),
+
+ new TableOfContents(),
+ new Video(),
+
+ new BulletList(),
+ new OrderedList(),
+ new ListItem(),
+
+ new DescriptionList(),
+ new DescriptionTerm(),
+ new DescriptionDetails(),
+
+ new TaskList(),
+ new OrderedTaskList(),
+ new TaskListItem(),
+
+ new Summary(),
+ new Details(),
+
+ new Bold(),
+ new Italic(),
+ new Strike(),
+ new InlineDiff(),
+
+ new Link(),
+ new Code(),
+ new MathMark(),
+ new InlineHTML(),
+];
diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js
new file mode 100644
index 00000000000..163182ab778
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/schema.js
@@ -0,0 +1,24 @@
+import { Schema } from 'prosemirror-model';
+import editorExtensions from './editor_extensions';
+
+const nodes = editorExtensions
+ .filter(extension => extension.type === 'node')
+ .reduce(
+ (ns, { name, schema }) => ({
+ ...ns,
+ [name]: schema,
+ }),
+ {},
+ );
+
+const marks = editorExtensions
+ .filter(extension => extension.type === 'mark')
+ .reduce(
+ (ms, { name, schema }) => ({
+ ...ms,
+ [name]: schema,
+ }),
+ {},
+ );
+
+export default new Schema({ nodes, marks });
diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js
new file mode 100644
index 00000000000..70dbd8bd206
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/serializer.js
@@ -0,0 +1,24 @@
+import { MarkdownSerializer } from 'prosemirror-markdown';
+import editorExtensions from './editor_extensions';
+
+const nodes = editorExtensions
+ .filter(extension => extension.type === 'node')
+ .reduce(
+ (ns, { name, toMarkdown }) => ({
+ ...ns,
+ [name]: toMarkdown,
+ }),
+ {},
+ );
+
+const marks = editorExtensions
+ .filter(extension => extension.type === 'mark')
+ .reduce(
+ (ms, { name, toMarkdown }) => ({
+ ...ms,
+ [name]: toMarkdown,
+ }),
+ {},
+ );
+
+export default new MarkdownSerializer(nodes, marks);
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 2918e1486a7..0eb067d4963 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import Mousetrap from 'mousetrap';
-import _ from 'underscore';
import Sidebar from '../../right_sidebar';
import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
@@ -63,18 +62,18 @@ export default class ShortcutsIssuable extends Shortcuts {
}
const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
- const selected = CopyAsGFM.nodeToGFM(el);
+ const blockquoteEl = document.createElement('blockquote');
+ blockquoteEl.appendChild(el);
+ const text = CopyAsGFM.nodeToGFM(blockquoteEl);
- if (selected.trim() === '') {
+ if (text.trim() === '') {
return false;
}
- const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`);
-
// If replyField already has some content, add a newline before our quote
const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
$replyField
- .val((a, current) => `${current}${separator}${quote.join('')}\n`)
+ .val((a, current) => `${current}${separator}${text}\n\n`)
.trigger('input')
.trigger('change');
diff --git a/lib/banzai/filter/emoji_filter.rb b/lib/banzai/filter/emoji_filter.rb
index c87948a30bf..fa1690f73ad 100644
--- a/lib/banzai/filter/emoji_filter.rb
+++ b/lib/banzai/filter/emoji_filter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/emoji.js
module Banzai
module Filter
# HTML filter that replaces :emoji: and unicode with images.
diff --git a/lib/banzai/filter/image_lazy_load_filter.rb b/lib/banzai/filter/image_lazy_load_filter.rb
index afaee70f351..d8b9eb29cf5 100644
--- a/lib/banzai/filter/image_lazy_load_filter.rb
+++ b/lib/banzai/filter/image_lazy_load_filter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/image.js
module Banzai
module Filter
# HTML filter that moves the value of image `src` attributes to `data-src`
diff --git a/lib/banzai/filter/image_link_filter.rb b/lib/banzai/filter/image_link_filter.rb
index 884a94fb761..01237303c27 100644
--- a/lib/banzai/filter/image_link_filter.rb
+++ b/lib/banzai/filter/image_link_filter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/image.js
module Banzai
module Filter
# HTML filter that wraps links around inline images.
diff --git a/lib/banzai/filter/inline_diff_filter.rb b/lib/banzai/filter/inline_diff_filter.rb
index e9ddc6e0e3d..5a1c0bee32d 100644
--- a/lib/banzai/filter/inline_diff_filter.rb
+++ b/lib/banzai/filter/inline_diff_filter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
module Banzai
module Filter
class InlineDiffFilter < HTML::Pipeline::Filter
diff --git a/lib/banzai/filter/math_filter.rb b/lib/banzai/filter/math_filter.rb
index 9d1bc3cf60c..8dd5a8979c8 100644
--- a/lib/banzai/filter/math_filter.rb
+++ b/lib/banzai/filter/math_filter.rb
@@ -2,6 +2,9 @@
require 'uri'
+# Generated HTML is transformed back to GFM by:
+# - app/assets/javascripts/behaviors/markdown/marks/math.js
+# - app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai
module Filter
# HTML filter that adds class="code math" and removes the dollar sign in $`2+2`$.
diff --git a/lib/banzai/filter/mermaid_filter.rb b/lib/banzai/filter/mermaid_filter.rb
index 7c8b165a330..f0adb83af8a 100644
--- a/lib/banzai/filter/mermaid_filter.rb
+++ b/lib/banzai/filter/mermaid_filter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai
module Filter
class MermaidFilter < HTML::Pipeline::Filter
diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb
index e5164e7f72a..42f9b3a689c 100644
--- a/lib/banzai/filter/reference_filter.rb
+++ b/lib/banzai/filter/reference_filter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/reference.js
module Banzai
module Filter
# Base class for GitLab Flavored Markdown reference filters.
diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb
index 18e5e9185de..bcf77861f10 100644
--- a/lib/banzai/filter/syntax_highlight_filter.rb
+++ b/lib/banzai/filter/syntax_highlight_filter.rb
@@ -3,6 +3,7 @@
require 'rouge/plugins/common_mark'
require 'rouge/plugins/redcarpet'
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai
module Filter
# HTML Filter to highlight fenced code blocks
diff --git a/lib/banzai/filter/table_of_contents_filter.rb b/lib/banzai/filter/table_of_contents_filter.rb
index c6d1e028eaa..f2ae17b44fa 100644
--- a/lib/banzai/filter/table_of_contents_filter.rb
+++ b/lib/banzai/filter/table_of_contents_filter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
module Banzai
module Filter
# HTML filter that adds an anchor child element to all Headers in a
diff --git a/lib/banzai/filter/task_list_filter.rb b/lib/banzai/filter/task_list_filter.rb
index ef35a49edcb..c6b402575cb 100644
--- a/lib/banzai/filter/task_list_filter.rb
+++ b/lib/banzai/filter/task_list_filter.rb
@@ -2,6 +2,10 @@
require 'task_list/filter'
+# Generated HTML is transformed back to GFM by:
+# - app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
+# - app/assets/javascripts/behaviors/markdown/nodes/task_list.js
+# - app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
module Banzai
module Filter
class TaskListFilter < TaskList::Filter
diff --git a/lib/banzai/filter/video_link_filter.rb b/lib/banzai/filter/video_link_filter.rb
index 0fb59c914c3..0fff104cf91 100644
--- a/lib/banzai/filter/video_link_filter.rb
+++ b/lib/banzai/filter/video_link_filter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
+# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/video.js
module Banzai
module Filter
# Find every image that isn't already wrapped in an `a` tag, and that has
diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb
index d860dad0b6c..30cafd11834 100644
--- a/lib/banzai/pipeline/gfm_pipeline.rb
+++ b/lib/banzai/pipeline/gfm_pipeline.rb
@@ -3,11 +3,11 @@
module Banzai
module Pipeline
class GfmPipeline < BasePipeline
- # These filters convert GitLab Flavored Markdown (GFM) to HTML.
- # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
- # consequently convert that same HTML to GFM to be copied to the clipboard.
- # Every filter that generates HTML from GFM should have a handler in
- # app/assets/javascripts/behaviors/markdown/copy_as_gfm.js, in reverse order.
+ # These filters transform GitLab Flavored Markdown (GFM) to HTML.
+ # The nodes and marks referenced in app/assets/javascripts/behaviors/markdown/editor_extensions.js
+ # consequently transform that same HTML to GFM to be copied to the clipboard.
+ # Every filter that generates HTML from GFM should have a node or mark in
+ # app/assets/javascripts/behaviors/markdown/editor_extensions.js.
# The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
def self.filters
@filters ||= FilterArray[
diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb
index 05228e27963..92dee494b7e 100644
--- a/spec/features/markdown/copy_as_gfm_spec.rb
+++ b/spec/features/markdown/copy_as_gfm_spec.rb
@@ -19,9 +19,9 @@ describe 'Copy as GFM', :js do
visit project_issue_path(@project, @feat.issue)
end
- # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML.
- # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js consequently convert that same HTML to GFM.
- # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle
+ # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform GitLab Flavored Markdown (GFM) to HTML.
+ # The nodes and marks referenced in app/assets/javascripts/behaviors/markdown/editor_extensions.js consequently transform that same HTML to GFM.
+ # To make sure these filters and nodes/marks are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle
# by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper.
# These are all in a single `it` for performance reasons.
@@ -35,12 +35,15 @@ describe 'Copy as GFM', :js do
verify(
'a real world example from the gitlab-ce README',
- <<-GFM.strip_heredoc
+ <<~GFM
# GitLab
[![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master)
+
[![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby)
+
[![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq)
+
[![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42)
## Canonical source
@@ -51,27 +54,31 @@ describe 'Copy as GFM', :js do
To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/).
- - Manage Git repositories with fine grained access controls that keep your code secure
+ * Manage Git repositories with fine grained access controls that keep your code secure
- - Perform code reviews and enhance collaboration with merge requests
+ * Perform code reviews and enhance collaboration with merge requests
- - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications
+ * Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications
- - Each project can also have an issue tracker, issue board, and a wiki
+ * Each project can also have an issue tracker, issue board, and a wiki
- - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises
+ * Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises
- - Completely free and open source (MIT Expat license)
+ * Completely free and open source (MIT Expat license)
GFM
)
aggregate_failures('an accidentally selected empty element') do
gfm = '# Heading1'
- html = <<-HTML.strip_heredoc
+ html = <<~HTML
<h1>Heading1</h1>
<h2></h2>
+
+ <blockquote></blockquote>
+
+ <pre class="code highlight"></pre>
HTML
output_gfm = html_to_gfm(html)
@@ -81,7 +88,7 @@ describe 'Copy as GFM', :js do
aggregate_failures('an accidentally selected other element') do
gfm = 'Test comment with **Markdown!**'
- html = <<-HTML.strip_heredoc
+ html = <<~HTML
<li class="note">
<div class="md">
<p>
@@ -107,10 +114,17 @@ describe 'Copy as GFM', :js do
verify(
'TaskListFilter',
- '- [ ] Unchecked task',
- '- [x] Checked task',
- '1. [ ] Unchecked numbered task',
- '1. [x] Checked numbered task'
+ <<~GFM,
+ * [ ] Unchecked task
+
+ * [x] Checked task
+ GFM
+
+ <<~GFM
+ 1. [ ] Unchecked ordered task
+
+ 1. [x] Checked ordered task
+ GFM
)
verify(
@@ -139,7 +153,16 @@ describe 'Copy as GFM', :js do
verify(
'TableOfContentsFilter',
- '[[_TOC_]]'
+ <<~GFM,
+ [[_TOC_]]
+
+ # Heading 1
+
+ ## Heading 2
+ GFM
+
+ pipeline: :wiki,
+ project_wiki: @project.wiki
)
verify(
@@ -166,7 +189,7 @@ describe 'Copy as GFM', :js do
'$`c = \pm\sqrt{a^2 + b^2}`$',
# math block
- <<-GFM.strip_heredoc
+ <<~GFM
```math
c = \pm\sqrt{a^2 + b^2}
```
@@ -176,7 +199,7 @@ describe 'Copy as GFM', :js do
aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do
gfm = '$`c = \pm\sqrt{a^2 + b^2}`$'
- html = <<-HTML.strip_heredoc
+ html = <<~HTML
<span class="katex">
<span class="katex-mathml">
<math>
@@ -287,7 +310,7 @@ describe 'Copy as GFM', :js do
verify(
'MermaidFilter: mermaid as converted from GFM to HTML',
- <<-GFM.strip_heredoc
+ <<~GFM
```mermaid
graph TD;
A-->B;
@@ -296,14 +319,14 @@ describe 'Copy as GFM', :js do
)
aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do
- gfm = <<-GFM.strip_heredoc
+ gfm = <<~GFM
```mermaid
graph TD;
A-->B;
```
GFM
- html = <<-HTML.strip_heredoc
+ html = <<~HTML
<svg id="mermaidChart1" xmlns="http://www.w3.org/2000/svg" height="100%" viewBox="0 0 87.234375 174" style="max-width:87.234375px;" class="mermaid">
<style>
.mermaid {
@@ -371,8 +394,7 @@ describe 'Copy as GFM', :js do
</g>
</g>
<text class="source" display="none">graph TD;
- A--&gt;B;
- </text>
+ A--&gt;B;</text>
</svg>
HTML
@@ -383,11 +405,18 @@ describe 'Copy as GFM', :js do
verify(
'SanitizationFilter',
- <<-GFM.strip_heredoc
+ <<~GFM
<sub>sub</sub>
<dl>
<dt>dt</dt>
+ <dt>dt</dt>
+ <dd>dd</dd>
+ <dd>dd</dd>
+
+ <dt>dt</dt>
+ <dt>dt</dt>
+ <dd>dd</dd>
<dd>dd</dd>
</dl>
@@ -399,30 +428,26 @@ describe 'Copy as GFM', :js do
<var>var</var>
- <ruby>ruby</ruby>
-
- <rt>rt</rt>
-
- <rp>rp</rp>
-
- <abbr>abbr</abbr>
+ <abbr title="HyperText &quot;Markup&quot; Language">HTML</abbr>
- <summary>summary</summary>
+ <details>
+ <summary>summary></summary>
- <details>details</details>
+ details
+ </details>
GFM
)
verify(
'SanitizationFilter',
- <<-GFM.strip_heredoc,
+ <<~GFM,
```
Plain text
```
GFM
- <<-GFM.strip_heredoc,
+ <<~GFM,
```ruby
def foo
bar
@@ -430,11 +455,9 @@ describe 'Copy as GFM', :js do
```
GFM
- <<-GFM.strip_heredoc
+ <<~GFM
Foo
- This is an example of GFM
-
```js
Code goes here
```
@@ -452,9 +475,8 @@ describe 'Copy as GFM', :js do
'> Quote',
# multiline quote
- <<-GFM.strip_heredoc,
- > Multiline
- > Quote
+ <<~GFM,
+ > Multiline Quote
>
> With multiple paragraphs
GFM
@@ -465,48 +487,58 @@ describe 'Copy as GFM', :js do
'[Link](https://example.com)',
- '- List item',
+ <<~GFM,
+ * List item
+
+ * List item 2
+ GFM
# multiline list item
- <<-GFM.strip_heredoc,
- - Multiline
- List item
+ <<~GFM,
+ * Multiline
+
+ List item
GFM
# nested lists
- <<-GFM.strip_heredoc,
- - Nested
+ <<~GFM,
+ * Nested
- - Lists
+ * Lists
GFM
# list with blockquote
- <<-GFM.strip_heredoc,
- - List
+ <<~GFM,
+ * List
- > Blockquote
+ > Blockquote
GFM
- '1. Numbered list item',
+ <<~GFM,
+ 1. Ordered list item
- # multiline numbered list item
- <<-GFM.strip_heredoc,
+ 1. Ordered list item 2
+ GFM
+
+ # multiline ordered list item
+ <<~GFM,
1. Multiline
- Numbered list item
+
+ Ordered list item
GFM
- # nested numbered list
- <<-GFM.strip_heredoc,
+ # nested ordered list
+ <<~GFM,
1. Nested
- 1. Numbered lists
+ 1. Ordered lists
GFM
# list item followed by an HR
- <<-GFM.strip_heredoc,
- - list item
+ <<~GFM,
+ * list item
- -----
+ ---
GFM
'# Heading',
@@ -518,14 +550,14 @@ describe 'Copy as GFM', :js do
'**Bold**',
- '_Italics_',
+ '*Italics*',
'~~Strikethrough~~',
- '-----',
+ '---',
# table
- <<-GFM.strip_heredoc,
+ <<~GFM,
| Centered | Right | Left |
|:--------:|------:|------|
| Foo | Bar | **Baz** |
@@ -533,9 +565,9 @@ describe 'Copy as GFM', :js do
GFM
# table with empty heading
- <<-GFM.strip_heredoc,
+ <<~GFM,
| | x | y |
- |---|---|---|
+ |--|---|---|
| a | 1 | 0 |
| b | 0 | 1 |
GFM
@@ -545,9 +577,11 @@ describe 'Copy as GFM', :js do
alias_method :gfm_to_html, :markdown
def verify(label, *gfms)
+ markdown_options = gfms.extract_options!
+
aggregate_failures(label) do
gfms.each do |gfm|
- html = gfm_to_html(gfm).gsub(/\A&#x000A;|&#x000A;\z/, '')
+ html = gfm_to_html(gfm, markdown_options).gsub(/\A&#x000A;|&#x000A;\z/, '')
output_gfm = html_to_gfm(html)
expect(output_gfm.strip).to eq(gfm.strip)
end
@@ -594,7 +628,7 @@ describe 'Copy as GFM', :js do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
- <<-GFM.strip_heredoc,
+ <<~GFM,
```ruby
raise RuntimeError, "System commands must be given as an array of strings"
end
@@ -627,7 +661,7 @@ describe 'Copy as GFM', :js do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
- <<-GFM.strip_heredoc,
+ <<~GFM,
```ruby
unless cmd.is_a?(Array)
raise "System commands must be given as an array of strings"
@@ -645,7 +679,7 @@ describe 'Copy as GFM', :js do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
- <<-GFM.strip_heredoc,
+ <<~GFM,
```ruby
unless cmd.is_a?(Array)
raise RuntimeError, "System commands must be given as an array of strings"
@@ -691,7 +725,7 @@ describe 'Copy as GFM', :js do
verify(
'.line[id="LC9"], .line[id="LC10"]',
- <<-GFM.strip_heredoc,
+ <<~GFM,
```ruby
raise RuntimeError, "System commands must be given as an array of strings"
end
@@ -733,7 +767,7 @@ describe 'Copy as GFM', :js do
verify(
'.line[id="LC27"], .line[id="LC28"]',
- <<-GFM.strip_heredoc,
+ <<~GFM,
```json
"bio": null,
"skype": "",
@@ -752,7 +786,7 @@ describe 'Copy as GFM', :js do
end
def html_for_selector(selector)
- js = <<-JS.strip_heredoc
+ js = <<~JS
(function(selector) {
var els = document.querySelectorAll(selector);
var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; });
@@ -763,7 +797,7 @@ describe 'Copy as GFM', :js do
end
def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil)
- js = <<-JS.strip_heredoc
+ js = <<~JS
(function(html) {
var transformer = window.CopyAsGFM[#{transformer.inspect}];
diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js
index cf8c1b77861..6179a02ce16 100644
--- a/spec/javascripts/behaviors/copy_as_gfm_spec.js
+++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js
@@ -87,7 +87,7 @@ describe('CopyAsGFM', () => {
spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy();
- const expectedGFM = '- List Item1\n- List Item2';
+ const expectedGFM = '* List Item1\n\n* List Item2';
expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
});
@@ -97,7 +97,7 @@ describe('CopyAsGFM', () => {
spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy();
- const expectedGFM = '1. List Item1\n1. List Item2';
+ const expectedGFM = '1. List Item1\n\n1. List Item2';
expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
});