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:
parent
a7e77e10fe
commit
8a03dbf8b7
19 changed files with 306 additions and 473 deletions
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
106
app/assets/javascripts/behaviors/markdown/editor_extensions.js
Normal file
106
app/assets/javascripts/behaviors/markdown/editor_extensions.js
Normal 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(),
|
||||||
|
];
|
24
app/assets/javascripts/behaviors/markdown/schema.js
Normal file
24
app/assets/javascripts/behaviors/markdown/schema.js
Normal 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 });
|
24
app/assets/javascripts/behaviors/markdown/serializer.js
Normal file
24
app/assets/javascripts/behaviors/markdown/serializer.js
Normal 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);
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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`
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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`$.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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[
|
||||||
|
|
|
@ -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-->B;
|
A-->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 "Markup" 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
|
\z/, '')
|
html = gfm_to_html(gfm, markdown_options).gsub(/\A
|
\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}];
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue