Use nodes and marks to power Copy-as-GFM

The spec needed to be updated because in some cases the resulting
Markdown is slightly different, though equally valid.
This commit is contained in:
Douwe Maan 2019-01-23 12:21:12 +01:00
parent a7e77e10fe
commit 8a03dbf8b7
No known key found for this signature in database
GPG key ID: 5976703F65143D36
19 changed files with 306 additions and 473 deletions

View file

@ -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 'jquery';
import _ from 'underscore'; import { DOMParser } from 'prosemirror-model';
import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils'; import { getSelectedFragment } from '~/lib/utils/common_utils';
import { placeholderImage } from '~/lazy_loader'; import schema from './schema';
import markdownSerializer from './serializer';
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(' | ')} |`;
},
},
};
export class CopyAsGFM { export class CopyAsGFM {
constructor() { constructor() {
@ -347,8 +35,13 @@ export class CopyAsGFM {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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/plain', el.textContent);
clipboardData.setData('text/x-gfm', this.nodeToGFM(el)); clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
clipboardData.setData('text/html', html);
} }
static pasteGFM(e) { static pasteGFM(e) {
@ -361,7 +54,7 @@ export class CopyAsGFM {
e.preventDefault(); 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, // 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 // we are either inside an inline code span that starts with 1 backtick
// or a code block that starts with 3 backticks. // or a code block that starts with 3 backticks.
@ -443,75 +136,12 @@ export class CopyAsGFM {
return codeElement; return codeElement;
} }
static nodeToGFM(node, respectWhitespaceParam = false) { static nodeToGFM(node) {
if (node.nodeType === Node.COMMENT_NODE) { const wrapEl = document.createElement('div');
return ''; wrapEl.appendChild(node.cloneNode(true));
} const doc = DOMParser.fromSchema(schema).parse(wrapEl);
if (node.nodeType === Node.TEXT_NODE) { return markdownSerializer.serialize(doc);
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();
}
return nodeText;
} }
} }

View file

@ -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(),
];

View file

@ -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 });

View file

@ -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);

View file

@ -1,6 +1,5 @@
import $ from 'jquery'; import $ from 'jquery';
import Mousetrap from 'mousetrap'; import Mousetrap from 'mousetrap';
import _ from 'underscore';
import Sidebar from '../../right_sidebar'; import Sidebar from '../../right_sidebar';
import Shortcuts from './shortcuts'; import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm'; 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 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; return false;
} }
const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`);
// If replyField already has some content, add a newline before our quote // If replyField already has some content, add a newline before our quote
const separator = ($replyField.val().trim() !== '' && '\n\n') || ''; const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
$replyField $replyField
.val((a, current) => `${current}${separator}${quote.join('')}\n`) .val((a, current) => `${current}${separator}${text}\n\n`)
.trigger('input') .trigger('input')
.trigger('change'); .trigger('change');

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/emoji.js
module Banzai module Banzai
module Filter module Filter
# HTML filter that replaces :emoji: and unicode with images. # HTML filter that replaces :emoji: and unicode with images.

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/image.js
module Banzai module Banzai
module Filter module Filter
# HTML filter that moves the value of image `src` attributes to `data-src` # HTML filter that moves the value of image `src` attributes to `data-src`

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/image.js
module Banzai module Banzai
module Filter module Filter
# HTML filter that wraps links around inline images. # HTML filter that wraps links around inline images.

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
module Banzai module Banzai
module Filter module Filter
class InlineDiffFilter < HTML::Pipeline::Filter class InlineDiffFilter < HTML::Pipeline::Filter

View file

@ -2,6 +2,9 @@
require 'uri' 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 Banzai
module Filter module Filter
# HTML filter that adds class="code math" and removes the dollar sign in $`2+2`$. # HTML filter that adds class="code math" and removes the dollar sign in $`2+2`$.

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai module Banzai
module Filter module Filter
class MermaidFilter < HTML::Pipeline::Filter class MermaidFilter < HTML::Pipeline::Filter

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/reference.js
module Banzai module Banzai
module Filter module Filter
# Base class for GitLab Flavored Markdown reference filters. # Base class for GitLab Flavored Markdown reference filters.

View file

@ -3,6 +3,7 @@
require 'rouge/plugins/common_mark' require 'rouge/plugins/common_mark'
require 'rouge/plugins/redcarpet' require 'rouge/plugins/redcarpet'
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai module Banzai
module Filter module Filter
# HTML Filter to highlight fenced code blocks # HTML Filter to highlight fenced code blocks

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # 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 Banzai
module Filter module Filter
# HTML filter that adds an anchor child element to all Headers in a # HTML filter that adds an anchor child element to all Headers in a

View file

@ -2,6 +2,10 @@
require 'task_list/filter' 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 Banzai
module Filter module Filter
class TaskListFilter < TaskList::Filter class TaskListFilter < TaskList::Filter

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/video.js
module Banzai module Banzai
module Filter module Filter
# Find every image that isn't already wrapped in an `a` tag, and that has # Find every image that isn't already wrapped in an `a` tag, and that has

View file

@ -3,11 +3,11 @@
module Banzai module Banzai
module Pipeline module Pipeline
class GfmPipeline < BasePipeline class GfmPipeline < BasePipeline
# These filters convert GitLab Flavored Markdown (GFM) to HTML. # These filters transform GitLab Flavored Markdown (GFM) to HTML.
# The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js # The nodes and marks referenced in app/assets/javascripts/behaviors/markdown/editor_extensions.js
# consequently convert that same HTML to GFM to be copied to the clipboard. # consequently transform that same HTML to GFM to be copied to the clipboard.
# Every filter that generates HTML from GFM should have a handler in # Every filter that generates HTML from GFM should have a node or mark in
# app/assets/javascripts/behaviors/markdown/copy_as_gfm.js, in reverse order. # 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. # The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
def self.filters def self.filters
@filters ||= FilterArray[ @filters ||= FilterArray[

View file

@ -19,9 +19,9 @@ describe 'Copy as GFM', :js do
visit project_issue_path(@project, @feat.issue) visit project_issue_path(@project, @feat.issue)
end end
# The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform 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. # 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 handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle # 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. # 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. # These are all in a single `it` for performance reasons.
@ -35,12 +35,15 @@ describe 'Copy as GFM', :js do
verify( verify(
'a real world example from the gitlab-ce README', 'a real world example from the gitlab-ce README',
<<-GFM.strip_heredoc <<~GFM
# GitLab # GitLab
[![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) [![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) [![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) [![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) [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42)
## Canonical source ## 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/). 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 GFM
) )
aggregate_failures('an accidentally selected empty element') do aggregate_failures('an accidentally selected empty element') do
gfm = '# Heading1' gfm = '# Heading1'
html = <<-HTML.strip_heredoc html = <<~HTML
<h1>Heading1</h1> <h1>Heading1</h1>
<h2></h2> <h2></h2>
<blockquote></blockquote>
<pre class="code highlight"></pre>
HTML HTML
output_gfm = html_to_gfm(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 aggregate_failures('an accidentally selected other element') do
gfm = 'Test comment with **Markdown!**' gfm = 'Test comment with **Markdown!**'
html = <<-HTML.strip_heredoc html = <<~HTML
<li class="note"> <li class="note">
<div class="md"> <div class="md">
<p> <p>
@ -107,10 +114,17 @@ describe 'Copy as GFM', :js do
verify( verify(
'TaskListFilter', 'TaskListFilter',
'- [ ] Unchecked task', <<~GFM,
'- [x] Checked task', * [ ] Unchecked task
'1. [ ] Unchecked numbered task',
'1. [x] Checked numbered task' * [x] Checked task
GFM
<<~GFM
1. [ ] Unchecked ordered task
1. [x] Checked ordered task
GFM
) )
verify( verify(
@ -139,7 +153,16 @@ describe 'Copy as GFM', :js do
verify( verify(
'TableOfContentsFilter', 'TableOfContentsFilter',
'[[_TOC_]]' <<~GFM,
[[_TOC_]]
# Heading 1
## Heading 2
GFM
pipeline: :wiki,
project_wiki: @project.wiki
) )
verify( verify(
@ -166,7 +189,7 @@ describe 'Copy as GFM', :js do
'$`c = \pm\sqrt{a^2 + b^2}`$', '$`c = \pm\sqrt{a^2 + b^2}`$',
# math block # math block
<<-GFM.strip_heredoc <<~GFM
```math ```math
c = \pm\sqrt{a^2 + b^2} 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 aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do
gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' gfm = '$`c = \pm\sqrt{a^2 + b^2}`$'
html = <<-HTML.strip_heredoc html = <<~HTML
<span class="katex"> <span class="katex">
<span class="katex-mathml"> <span class="katex-mathml">
<math> <math>
@ -287,7 +310,7 @@ describe 'Copy as GFM', :js do
verify( verify(
'MermaidFilter: mermaid as converted from GFM to HTML', 'MermaidFilter: mermaid as converted from GFM to HTML',
<<-GFM.strip_heredoc <<~GFM
```mermaid ```mermaid
graph TD; graph TD;
A-->B; A-->B;
@ -296,14 +319,14 @@ describe 'Copy as GFM', :js do
) )
aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do
gfm = <<-GFM.strip_heredoc gfm = <<~GFM
```mermaid ```mermaid
graph TD; graph TD;
A-->B; A-->B;
``` ```
GFM 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"> <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> <style>
.mermaid { .mermaid {
@ -371,8 +394,7 @@ describe 'Copy as GFM', :js do
</g> </g>
</g> </g>
<text class="source" display="none">graph TD; <text class="source" display="none">graph TD;
A--&gt;B; A--&gt;B;</text>
</text>
</svg> </svg>
HTML HTML
@ -383,11 +405,18 @@ describe 'Copy as GFM', :js do
verify( verify(
'SanitizationFilter', 'SanitizationFilter',
<<-GFM.strip_heredoc <<~GFM
<sub>sub</sub> <sub>sub</sub>
<dl> <dl>
<dt>dt</dt> <dt>dt</dt>
<dt>dt</dt>
<dd>dd</dd>
<dd>dd</dd>
<dt>dt</dt>
<dt>dt</dt>
<dd>dd</dd>
<dd>dd</dd> <dd>dd</dd>
</dl> </dl>
@ -399,30 +428,26 @@ describe 'Copy as GFM', :js do
<var>var</var> <var>var</var>
<ruby>ruby</ruby> <abbr title="HyperText &quot;Markup&quot; Language">HTML</abbr>
<rt>rt</rt> <details>
<summary>summary></summary>
<rp>rp</rp> details
</details>
<abbr>abbr</abbr>
<summary>summary</summary>
<details>details</details>
GFM GFM
) )
verify( verify(
'SanitizationFilter', 'SanitizationFilter',
<<-GFM.strip_heredoc, <<~GFM,
``` ```
Plain text Plain text
``` ```
GFM GFM
<<-GFM.strip_heredoc, <<~GFM,
```ruby ```ruby
def foo def foo
bar bar
@ -430,11 +455,9 @@ describe 'Copy as GFM', :js do
``` ```
GFM GFM
<<-GFM.strip_heredoc <<~GFM
Foo Foo
This is an example of GFM
```js ```js
Code goes here Code goes here
``` ```
@ -452,9 +475,8 @@ describe 'Copy as GFM', :js do
'> Quote', '> Quote',
# multiline quote # multiline quote
<<-GFM.strip_heredoc, <<~GFM,
> Multiline > Multiline Quote
> Quote
> >
> With multiple paragraphs > With multiple paragraphs
GFM GFM
@ -465,48 +487,58 @@ describe 'Copy as GFM', :js do
'[Link](https://example.com)', '[Link](https://example.com)',
'- List item', <<~GFM,
* List item
* List item 2
GFM
# multiline list item # multiline list item
<<-GFM.strip_heredoc, <<~GFM,
- Multiline * Multiline
List item List item
GFM GFM
# nested lists # nested lists
<<-GFM.strip_heredoc, <<~GFM,
- Nested * Nested
- Lists * Lists
GFM GFM
# list with blockquote # list with blockquote
<<-GFM.strip_heredoc, <<~GFM,
- List * List
> Blockquote > Blockquote
GFM GFM
'1. Numbered list item', <<~GFM,
1. Ordered list item
# multiline numbered list item 1. Ordered list item 2
<<-GFM.strip_heredoc,
1. Multiline
Numbered list item
GFM GFM
# nested numbered list # multiline ordered list item
<<-GFM.strip_heredoc, <<~GFM,
1. Multiline
Ordered list item
GFM
# nested ordered list
<<~GFM,
1. Nested 1. Nested
1. Numbered lists 1. Ordered lists
GFM GFM
# list item followed by an HR # list item followed by an HR
<<-GFM.strip_heredoc, <<~GFM,
- list item * list item
----- ---
GFM GFM
'# Heading', '# Heading',
@ -518,14 +550,14 @@ describe 'Copy as GFM', :js do
'**Bold**', '**Bold**',
'_Italics_', '*Italics*',
'~~Strikethrough~~', '~~Strikethrough~~',
'-----', '---',
# table # table
<<-GFM.strip_heredoc, <<~GFM,
| Centered | Right | Left | | Centered | Right | Left |
|:--------:|------:|------| |:--------:|------:|------|
| Foo | Bar | **Baz** | | Foo | Bar | **Baz** |
@ -533,9 +565,9 @@ describe 'Copy as GFM', :js do
GFM GFM
# table with empty heading # table with empty heading
<<-GFM.strip_heredoc, <<~GFM,
| | x | y | | | x | y |
|---|---|---| |--|---|---|
| a | 1 | 0 | | a | 1 | 0 |
| b | 0 | 1 | | b | 0 | 1 |
GFM GFM
@ -545,9 +577,11 @@ describe 'Copy as GFM', :js do
alias_method :gfm_to_html, :markdown alias_method :gfm_to_html, :markdown
def verify(label, *gfms) def verify(label, *gfms)
markdown_options = gfms.extract_options!
aggregate_failures(label) do aggregate_failures(label) do
gfms.each do |gfm| 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) output_gfm = html_to_gfm(html)
expect(output_gfm.strip).to eq(gfm.strip) expect(output_gfm.strip).to eq(gfm.strip)
end end
@ -594,7 +628,7 @@ describe 'Copy as GFM', :js do
verify( verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
<<-GFM.strip_heredoc, <<~GFM,
```ruby ```ruby
raise RuntimeError, "System commands must be given as an array of strings" raise RuntimeError, "System commands must be given as an array of strings"
end end
@ -627,7 +661,7 @@ describe 'Copy as GFM', :js do
verify( verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
<<-GFM.strip_heredoc, <<~GFM,
```ruby ```ruby
unless cmd.is_a?(Array) unless cmd.is_a?(Array)
raise "System commands must be given as an array of strings" raise "System commands must be given as an array of strings"
@ -645,7 +679,7 @@ describe 'Copy as GFM', :js do
verify( verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]', '[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
<<-GFM.strip_heredoc, <<~GFM,
```ruby ```ruby
unless cmd.is_a?(Array) unless cmd.is_a?(Array)
raise RuntimeError, "System commands must be given as an array of strings" raise RuntimeError, "System commands must be given as an array of strings"
@ -691,7 +725,7 @@ describe 'Copy as GFM', :js do
verify( verify(
'.line[id="LC9"], .line[id="LC10"]', '.line[id="LC9"], .line[id="LC10"]',
<<-GFM.strip_heredoc, <<~GFM,
```ruby ```ruby
raise RuntimeError, "System commands must be given as an array of strings" raise RuntimeError, "System commands must be given as an array of strings"
end end
@ -733,7 +767,7 @@ describe 'Copy as GFM', :js do
verify( verify(
'.line[id="LC27"], .line[id="LC28"]', '.line[id="LC27"], .line[id="LC28"]',
<<-GFM.strip_heredoc, <<~GFM,
```json ```json
"bio": null, "bio": null,
"skype": "", "skype": "",
@ -752,7 +786,7 @@ describe 'Copy as GFM', :js do
end end
def html_for_selector(selector) def html_for_selector(selector)
js = <<-JS.strip_heredoc js = <<~JS
(function(selector) { (function(selector) {
var els = document.querySelectorAll(selector); var els = document.querySelectorAll(selector);
var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; }); var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; });
@ -763,7 +797,7 @@ describe 'Copy as GFM', :js do
end end
def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil) def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil)
js = <<-JS.strip_heredoc js = <<~JS
(function(html) { (function(html) {
var transformer = window.CopyAsGFM[#{transformer.inspect}]; var transformer = window.CopyAsGFM[#{transformer.inspect}];

View file

@ -87,7 +87,7 @@ describe('CopyAsGFM', () => {
spyOn(window, 'getSelection').and.returnValue(selection); spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy(); simulateCopy();
const expectedGFM = '- List Item1\n- List Item2'; const expectedGFM = '* List Item1\n\n* List Item2';
expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM); expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
}); });
@ -97,7 +97,7 @@ describe('CopyAsGFM', () => {
spyOn(window, 'getSelection').and.returnValue(selection); spyOn(window, 'getSelection').and.returnValue(selection);
simulateCopy(); 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); expect(clipboardData.setData).toHaveBeenCalledWith('text/x-gfm', expectedGFM);
}); });