Merge branch 'copy-as-md' into 'master'

Copying a rendered issue/comment will paste into GFM textareas as actual GFM

See merge request !8597
This commit is contained in:
Jacob Schatz 2017-01-25 22:14:01 +00:00
commit a24e9a0e3c
12 changed files with 911 additions and 41 deletions

View File

@ -0,0 +1,355 @@
/* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */
/* jshint esversion: 6 */
/*= require lib/utils/common_utils */
(() => {
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, text) {
return `[${el.checked ? 'x' : ' '}]`;
},
},
ReferenceFilter: {
'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, text) {
return '[[_TOC_]]';
},
},
EmojiFilter: {
'img.emoji'(el, text) {
return el.getAttribute('alt');
},
},
ImageLinkFilter: {
'a.no-attachment-icon'(el, text) {
return text;
},
},
VideoLinkFilter: {
'.video-container'(el, text) {
const videoEl = el.querySelector('video');
if (!videoEl) return false;
return CopyAsGFM.nodeToGFM(videoEl);
},
'video'(el, text) {
return `![${el.dataset.title}](${el.getAttribute('src')})`;
},
},
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, text) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
},
'span.katex-mathml'(el, text) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
},
'span.katex-html'(el, text) {
// 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: {
'dl'(el, text) {
let lines = text.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>`;
},
'sub, dt, dd, 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.trim();
let lang = el.getAttribute('lang');
if (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, text) {
// 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 + spaceOrNoSpace + backticks;
},
'blockquote'(el, text) {
return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n');
},
'img'(el, text) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
},
'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(/^- /mg, '1. ');
},
'h1'(el, text) {
return `# ${text.trim()}`;
},
'h2'(el, text) {
return `## ${text.trim()}`;
},
'h3'(el, text) {
return `### ${text.trim()}`;
},
'h4'(el, text) {
return `#### ${text.trim()}`;
},
'h5'(el, text) {
return `##### ${text.trim()}`;
},
'h6'(el, text) {
return `###### ${text.trim()}`;
},
'strong'(el, text) {
return `**${text}**`;
},
'em'(el, text) {
return `_${text}_`;
},
'del'(el, text) {
return `~~${text}~~`;
},
'sup'(el, text) {
return `^${text}`;
},
'hr'(el, text) {
return '-----';
},
'table'(el, text) {
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;
},
'thead'(el, text) {
const cells = _.map(el.querySelectorAll('th'), (cell) => {
let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2;
let before = '';
let after = '';
switch (cell.style.textAlign) {
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;
});
return `${text}|${cells.join('|')}|`;
},
'tr'(el, text) {
const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim());
return `| ${cells.join(' | ')} |`;
},
},
};
class CopyAsGFM {
constructor() {
$(document).on('copy', '.md, .wiki', this.handleCopy);
$(document).on('paste', '.js-gfm-input', this.handlePaste);
}
handleCopy(e) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
const documentFragment = window.gl.utils.getSelectedFragment();
if (!documentFragment) return;
// If the documentFragment contains more than just Markdown, don't copy as GFM.
if (documentFragment.querySelector('.md, .wiki')) return;
e.preventDefault();
clipboardData.setData('text/plain', documentFragment.textContent);
const gfm = CopyAsGFM.nodeToGFM(documentFragment);
clipboardData.setData('text/x-gfm', gfm);
}
handlePaste(e) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
const gfm = clipboardData.getData('text/x-gfm');
if (!gfm) return;
e.preventDefault();
window.gl.utils.insertText(e.target, gfm);
}
static nodeToGFM(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent;
}
const text = this.innerGFM(node);
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 (!window.gl.utils.nodeMatchesSelector(node, selector)) continue;
const result = func(node, text);
if (result === false) continue;
return result;
}
}
return text;
}
static innerGFM(parentNode) {
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);
// `clonedNode.replaceWith(text)` is not yet widely supported
clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
}
return clonedParentNode.innerText || clonedParentNode.textContent;
}
}
window.gl = window.gl || {};
window.gl.CopyAsGFM = CopyAsGFM;
new CopyAsGFM();
})();

View File

@ -160,6 +160,62 @@
return decodeURIComponent(results[2].replace(/\+/g, ' '));
};
w.gl.utils.getSelectedFragment = () => {
const selection = window.getSelection();
const documentFragment = selection.getRangeAt(0).cloneContents();
if (documentFragment.textContent.length === 0) return null;
return documentFragment;
};
w.gl.utils.insertText = (target, text) => {
// Firefox doesn't support `document.execCommand('insertText', false, text)` on textareas
const selectionStart = target.selectionStart;
const selectionEnd = target.selectionEnd;
const value = target.value;
const textBefore = value.substring(0, selectionStart);
const textAfter = value.substring(selectionEnd, value.length);
const newText = textBefore + text + textAfter;
target.value = newText;
target.selectionStart = target.selectionEnd = selectionStart + text.length;
// Trigger autosave
$(target).trigger('input');
// Trigger autosize
var event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
target.dispatchEvent(event);
};
w.gl.utils.nodeMatchesSelector = (node, selector) => {
const matches = Element.prototype.matches ||
Element.prototype.matchesSelector ||
Element.prototype.mozMatchesSelector ||
Element.prototype.msMatchesSelector ||
Element.prototype.oMatchesSelector ||
Element.prototype.webkitMatchesSelector;
if (matches) {
return matches.call(node, selector);
}
// IE11 doesn't support `node.matches(selector)`
let parentNode = node.parentNode;
if (!parentNode) {
parentNode = document.createElement('div');
node = node.cloneNode(true);
parentNode.appendChild(node);
}
const matchingNodes = parentNode.querySelectorAll(selector);
return Array.prototype.indexOf.call(matchingNodes, node) !== -1;
};
/**
this will take in the headers from an API response and normalize them
this way we don't run into production issues when nginx gives us lowercased header keys

View File

@ -39,29 +39,39 @@
}
ShortcutsIssuable.prototype.replyWithSelectedText = function() {
var quote, replyField, selected, separator;
if (window.getSelection) {
selected = window.getSelection().toString();
replyField = $('.js-main-target-form #note_note');
if (selected.trim() === "") {
return;
}
// Put a '>' character before each non-empty line in the selection
quote = _.map(selected.split("\n"), function(val) {
if (val.trim() !== '') {
return "> " + val + "\n";
}
});
// If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n" || '';
replyField.val(function(_, current) {
return current + separator + quote.join('') + "\n";
});
// Trigger autosave for the added text
replyField.trigger('input');
// Focus the input field
return replyField.focus();
var quote, replyField, documentFragment, selected, separator;
documentFragment = window.gl.utils.getSelectedFragment();
if (!documentFragment) return;
// If the documentFragment contains more than just Markdown, don't copy as GFM.
if (documentFragment.querySelector('.md, .wiki')) return;
selected = window.gl.CopyAsGFM.nodeToGFM(documentFragment);
replyField = $('.js-main-target-form #note_note');
if (selected.trim() === "") {
return;
}
quote = _.map(selected.split("\n"), function(val) {
return ("> " + val).trim() + "\n";
});
// If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n\n" || '';
replyField.val(function(_, current) {
return current + separator + quote.join('') + "\n";
});
// Trigger autosave
replyField.trigger('input');
// Trigger autosize
var event = document.createEvent('Event');
event.initEvent('autosize:update', true, false);
replyField.get(0).dispatchEvent(event);
// Focus the input field
return replyField.focus();
};
ShortcutsIssuable.prototype.editIssue = function() {

View File

@ -0,0 +1,4 @@
---
title: Copying a rendered issue/comment will paste into GFM textareas as actual GFM
merge_request:
author:

View File

@ -153,7 +153,7 @@ module Banzai
title = object_link_title(object)
klass = reference_class(object_sym)
data = data_attributes_for(link_content || match, project, object)
data = data_attributes_for(link_content || match, project, object, link: !!link_content)
if matches.names.include?("url") && matches[:url]
url = matches[:url]
@ -172,9 +172,10 @@ module Banzai
end
end
def data_attributes_for(text, project, object)
def data_attributes_for(text, project, object, link: false)
data_attribute(
original: text,
link: link,
project: project.id,
object_sym => object.id
)

View File

@ -62,7 +62,7 @@ module Banzai
end
end
def data_attributes_for(text, project, object)
def data_attributes_for(text, project, object, link: false)
if object.is_a?(ExternalIssue)
data_attribute(
project: project.id,

View File

@ -20,17 +20,19 @@ module Banzai
code = node.text
css_classes = "code highlight"
lexer = lexer_for(language)
lang = lexer.tag
begin
code = format(lex(lexer, code))
css_classes << " js-syntax-highlight #{lexer.tag}"
css_classes << " js-syntax-highlight #{lang}"
rescue
lang = nil
# Gracefully handle syntax highlighter bugs/errors to ensure
# users can still access an issue/comment/etc.
end
highlighted = %(<pre class="#{css_classes}" v-pre="true"><code>#{code}</code></pre>)
highlighted = %(<pre class="#{css_classes}" lang="#{lang}" v-pre="true"><code>#{code}</code></pre>)
# Extracted to a method to measure it
replace_parent_pre_element(node, highlighted)

View File

@ -35,7 +35,8 @@ module Banzai
src: element['src'],
width: '400',
controls: true,
'data-setup' => '{}')
'data-setup' => '{}',
'data-title' => element['title'] || element['alt'])
link = doc.document.create_element(
'a',

View File

@ -1,6 +1,12 @@
module Banzai
module Pipeline
class GfmPipeline < BasePipeline
# These filters convert GitLab Flavored Markdown (GFM) to HTML.
# The handlers defined in app/assets/javascripts/copy_as_gfm.js.es6
# 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/copy_as_gfm.js.es6, in reverse order.
# The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
def self.filters
@filters ||= FilterArray[
Filter::SyntaxHighlightFilter,

View File

@ -0,0 +1,432 @@
require 'spec_helper'
describe 'Copy as GFM', feature: true, js: true do
include GitlabMarkdownHelper
include ActionView::Helpers::JavaScriptHelper
before do
@feat = MarkdownFeature.new
# `markdown` helper expects a `@project` variable
@project = @feat.project
visit namespace_project_issue_path(@project.namespace, @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/copy_as_gfm.js.es6 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
# 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.
it 'works', :aggregate_failures do
verify(
'nesting',
'> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**'
)
verify(
'a real world example from the gitlab-ce README',
<<-GFM.strip_heredoc
# 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
The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/).
## Open source software to collaborate on code
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
- Perform code reviews and enhance collaboration with merge requests
- 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
- 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)
GFM
)
verify(
'InlineDiffFilter',
'{-Deleted text-}',
'{+Added text+}'
)
verify(
'TaskListFilter',
'- [ ] Unchecked task',
'- [x] Checked task',
'1. [ ] Unchecked numbered task',
'1. [x] Checked numbered task'
)
verify(
'ReferenceFilter',
# issue reference
@feat.issue.to_reference,
# full issue reference
@feat.issue.to_reference(full: true),
# issue URL
namespace_project_issue_url(@project.namespace, @project, @feat.issue),
# issue URL with note anchor
namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123'),
# issue link
"[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})",
# issue link with note anchor
"[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})",
)
verify(
'AutolinkFilter',
'https://example.com'
)
verify(
'TableOfContentsFilter',
'[[_TOC_]]'
)
verify(
'EmojiFilter',
':thumbsup:'
)
verify(
'ImageLinkFilter',
'![Image](https://example.com/image.png)'
)
verify(
'VideoLinkFilter',
'![Video](https://example.com/video.mp4)'
)
verify(
'MathFilter: math as converted from GFM to HTML',
'$`c = \pm\sqrt{a^2 + b^2}`$',
# math block
<<-GFM.strip_heredoc
```math
c = \pm\sqrt{a^2 + b^2}
```
GFM
)
aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do
gfm = '$`c = \pm\sqrt{a^2 + b^2}`$'
html = <<-HTML.strip_heredoc
<span class="katex">
<span class="katex-mathml">
<math>
<semantics>
<mrow>
<mi>c</mi>
<mo>=</mo>
<mo>±</mo>
<msqrt>
<mrow>
<msup>
<mi>a</mi>
<mn>2</mn>
</msup>
<mo>+</mo>
<msup>
<mi>b</mi>
<mn>2</mn>
</msup>
</mrow>
</msqrt>
</mrow>
<annotation encoding="application/x-tex">c = \\pm\\sqrt{a^2 + b^2}</annotation>
</semantics>
</math>
</span>
<span class="katex-html" aria-hidden="true">
<span class="strut" style="height: 0.913389em;"></span>
<span class="strut bottom" style="height: 1.04em; vertical-align: -0.126611em;"></span>
<span class="base textstyle uncramped">
<span class="mord mathit">c</span>
<span class="mrel">=</span>
<span class="mord">±</span>
<span class="sqrt mord"><span class="sqrt-sign" style="top: -0.073389em;">
<span class="style-wrap reset-textstyle textstyle uncramped"></span>
</span>
<span class="vlist">
<span class="" style="top: 0em;">
<span class="fontsize-ensurer reset-size5 size5">
<span class="" style="font-size: 1em;"></span>
</span>
<span class="mord textstyle cramped">
<span class="mord">
<span class="mord mathit">a</span>
<span class="msupsub">
<span class="vlist">
<span class="" style="top: -0.289em; margin-right: 0.05em;">
<span class="fontsize-ensurer reset-size5 size5">
<span class="" style="font-size: 0em;"></span>
</span>
<span class="reset-textstyle scriptstyle cramped">
<span class="mord mathrm">2</span>
</span>
</span>
<span class="baseline-fix">
<span class="fontsize-ensurer reset-size5 size5">
<span class="" style="font-size: 0em;"></span>
</span>
</span>
</span>
</span>
</span>
<span class="mbin">+</span>
<span class="mord">
<span class="mord mathit">b</span>
<span class="msupsub">
<span class="vlist">
<span class="" style="top: -0.289em; margin-right: 0.05em;">
<span class="fontsize-ensurer reset-size5 size5">
<span class="" style="font-size: 0em;"></span>
</span>
<span class="reset-textstyle scriptstyle cramped">
<span class="mord mathrm">2</span>
</span>
</span>
<span class="baseline-fix">
<span class="fontsize-ensurer reset-size5 size5">
<span class="" style="font-size: 0em;"></span>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
<span class="" style="top: -0.833389em;">
<span class="fontsize-ensurer reset-size5 size5">
<span class="" style="font-size: 1em;"></span>
</span>
<span class="reset-textstyle textstyle uncramped sqrt-line"></span>
</span>
<span class="baseline-fix">
<span class="fontsize-ensurer reset-size5 size5">
<span class="" style="font-size: 1em;"></span>
</span>
</span>
</span>
</span>
</span>
</span>
</span>
HTML
output_gfm = html_to_gfm(html)
expect(output_gfm.strip).to eq(gfm.strip)
end
verify(
'SanitizationFilter',
<<-GFM.strip_heredoc
<sub>sub</sub>
<dl>
<dt>dt</dt>
<dd>dd</dd>
</dl>
<kbd>kbd</kbd>
<q>q</q>
<samp>samp</samp>
<var>var</var>
<ruby>ruby</ruby>
<rt>rt</rt>
<rp>rp</rp>
<abbr>abbr</abbr>
GFM
)
verify(
'SanitizationFilter',
<<-GFM.strip_heredoc,
```
Plain text
```
GFM
<<-GFM.strip_heredoc,
```ruby
def foo
bar
end
```
GFM
<<-GFM.strip_heredoc
Foo
This is an example of GFM
```js
Code goes here
```
GFM
)
verify(
'MarkdownFilter',
"Line with two spaces at the end \nto insert a linebreak",
'`code`',
'`` code with ` ticks ``',
'> Quote',
# multiline quote
<<-GFM.strip_heredoc,
> Multiline
> Quote
>
> With multiple paragraphs
GFM
'![Image](https://example.com/image.png)',
'# Heading with no anchor link',
'[Link](https://example.com)',
'- List item',
# multiline list item
<<-GFM.strip_heredoc,
- Multiline
List item
GFM
# nested lists
<<-GFM.strip_heredoc,
- Nested
- Lists
GFM
# list with blockquote
<<-GFM.strip_heredoc,
- List
> Blockquote
GFM
'1. Numbered list item',
# multiline numbered list item
<<-GFM.strip_heredoc,
1. Multiline
Numbered list item
GFM
# nested numbered list
<<-GFM.strip_heredoc,
1. Nested
1. Numbered lists
GFM
'# Heading',
'## Heading',
'### Heading',
'#### Heading',
'##### Heading',
'###### Heading',
'**Bold**',
'_Italics_',
'~~Strikethrough~~',
'2^2',
'-----',
# table
<<-GFM.strip_heredoc,
| Centered | Right | Left |
|:--------:|------:|------|
| Foo | Bar | **Baz** |
| Foo | Bar | **Baz** |
GFM
# table with empty heading
<<-GFM.strip_heredoc,
| | x | y |
|---|---|---|
| a | 1 | 0 |
| b | 0 | 1 |
GFM
)
end
alias_method :gfm_to_html, :markdown
def html_to_gfm(html)
js = <<-JS.strip_heredoc
(function(html) {
var node = document.createElement('div');
node.innerHTML = html;
return window.gl.CopyAsGFM.nodeToGFM(node);
})("#{escape_javascript(html)}")
JS
page.evaluate_script(js)
end
def verify(label, *gfms)
aggregate_failures(label) do
gfms.each do |gfm|
html = gfm_to_html(gfm)
output_gfm = html_to_gfm(html)
expect(output_gfm.strip).to eq(gfm.strip)
end
end
end
# Fake a `current_user` helper
def current_user
@feat.user
end
end

View File

@ -1,6 +1,7 @@
/* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */
/* global ShortcutsIssuable */
/*= require copy_as_gfm */
/*= require shortcuts_issuable */
(function() {
@ -14,10 +15,12 @@
});
return describe('#replyWithSelectedText', function() {
var stubSelection;
// Stub window.getSelection to return the provided String.
stubSelection = function(text) {
return window.getSelection = function() {
return text;
// Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
stubSelection = function(html) {
window.gl.utils.getSelectedFragment = function() {
var node = document.createElement('div');
node.innerHTML = html;
return node;
};
};
beforeEach(function() {
@ -32,13 +35,13 @@
});
describe('with any selection', function() {
beforeEach(function() {
return stubSelection('Selected text.');
return stubSelection('<p>Selected text.</p>');
});
it('leaves existing input intact', function() {
$(this.selector).val('This text was already here.');
expect($(this.selector).val()).toBe('This text was already here.');
this.shortcut.replyWithSelectedText();
return expect($(this.selector).val()).toBe("This text was already here.\n> Selected text.\n\n");
return expect($(this.selector).val()).toBe("This text was already here.\n\n> Selected text.\n\n");
});
it('triggers `input`', function() {
var triggered;
@ -61,16 +64,16 @@
});
describe('with a one-line selection', function() {
return it('quotes the selection', function() {
stubSelection('This text has been selected.');
stubSelection('<p>This text has been selected.</p>');
this.shortcut.replyWithSelectedText();
return expect($(this.selector).val()).toBe("> This text has been selected.\n\n");
});
});
return describe('with a multi-line selection', function() {
return it('quotes the selected lines as a group', function() {
stubSelection("Selected line one.\n\nSelected line two.\nSelected line three.\n");
stubSelection("<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>");
this.shortcut.replyWithSelectedText();
return expect($(this.selector).val()).toBe("> Selected line one.\n> Selected line two.\n> Selected line three.\n\n");
return expect($(this.selector).val()).toBe("> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n");
});
});
});

View File

@ -6,21 +6,21 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do
context "when no language is specified" do
it "highlights as plaintext" do
result = filter('<pre><code>def fun end</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" v-pre="true"><code>def fun end</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code>def fun end</code></pre>')
end
end
context "when a valid language is specified" do
it "highlights as that language" do
result = filter('<pre><code class="ruby">def fun end</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby" v-pre="true"><code><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight ruby" lang="ruby" v-pre="true"><code><span class="k">def</span> <span class="nf">fun</span> <span class="k">end</span></code></pre>')
end
end
context "when an invalid language is specified" do
it "highlights as plaintext" do
result = filter('<pre><code class="gnuplot">This is a test</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" v-pre="true"><code>This is a test</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight js-syntax-highlight plaintext" lang="plaintext" v-pre="true"><code>This is a test</code></pre>')
end
end
@ -31,7 +31,7 @@ describe Banzai::Filter::SyntaxHighlightFilter, lib: true do
it "highlights as plaintext" do
result = filter('<pre><code class="ruby">This is a test</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight" v-pre="true"><code>This is a test</code></pre>')
expect(result.to_html).to eq('<pre class="code highlight" lang="" v-pre="true"><code>This is a test</code></pre>')
end
end
end