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:
commit
a24e9a0e3c
|
@ -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();
|
||||
})();
|
|
@ -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
|
||||
|
|
|
@ -39,29 +39,39 @@
|
|||
}
|
||||
|
||||
ShortcutsIssuable.prototype.replyWithSelectedText = function() {
|
||||
var quote, replyField, selected, separator;
|
||||
if (window.getSelection) {
|
||||
selected = window.getSelection().toString();
|
||||
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;
|
||||
}
|
||||
// Put a '>' character before each non-empty line in the selection
|
||||
quote = _.map(selected.split("\n"), function(val) {
|
||||
if (val.trim() !== '') {
|
||||
return "> " + val + "\n";
|
||||
}
|
||||
return ("> " + val).trim() + "\n";
|
||||
});
|
||||
// If replyField already has some content, add a newline before our quote
|
||||
separator = replyField.val().trim() !== "" && "\n" || '';
|
||||
separator = replyField.val().trim() !== "" && "\n\n" || '';
|
||||
replyField.val(function(_, current) {
|
||||
return current + separator + quote.join('') + "\n";
|
||||
});
|
||||
// Trigger autosave for the added text
|
||||
|
||||
// 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() {
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Copying a rendered issue/comment will paste into GFM textareas as actual GFM
|
||||
merge_request:
|
||||
author:
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue