Use nodes and marks to power Copy-as-GFM

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

View file

@ -1,320 +1,8 @@
/* eslint-disable object-shorthand, no-unused-vars, no-use-before-define, no-restricted-syntax, guard-for-in, no-continue */
import $ from 'jquery';
import _ from 'underscore';
import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils';
import { placeholderImage } from '~/lazy_loader';
const gfmRules = {
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
// GitLab Flavored Markdown (GFM) to HTML.
// These handlers consequently convert that same HTML to GFM to be copied to the clipboard.
// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
// from GFM should have a handler here, in reverse order.
// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
InlineDiffFilter: {
'span.idiff.addition'(el, text) {
return `{+${text}+}`;
},
'span.idiff.deletion'(el, text) {
return `{-${text}-}`;
},
},
TaskListFilter: {
'input[type=checkbox].task-list-item-checkbox'(el) {
return `[${el.checked ? 'x' : ' '}]`;
},
},
ReferenceFilter: {
'.tooltip'(el) {
return '';
},
'a.gfm:not([data-link=true])'(el, text) {
return el.dataset.original || text;
},
},
AutolinkFilter: {
a(el, text) {
// Fallback on the regular MarkdownFilter's `a` handler.
if (text !== el.getAttribute('href')) return false;
return text;
},
},
TableOfContentsFilter: {
'ul.section-nav'(el) {
return '[[_TOC_]]';
},
},
EmojiFilter: {
'img.emoji'(el) {
return el.getAttribute('alt');
},
'gl-emoji'(el) {
return `:${el.getAttribute('data-name')}:`;
},
},
ImageLinkFilter: {
'a.no-attachment-icon'(el, text) {
return text;
},
},
ImageLazyLoadFilter: {
img(el, text) {
return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
},
},
VideoLinkFilter: {
'.video-container'(el) {
const videoEl = el.querySelector('video');
if (!videoEl) return false;
return CopyAsGFM.nodeToGFM(videoEl);
},
video(el) {
return `![${el.dataset.title}](${el.getAttribute('src')})`;
},
},
MermaidFilter: {
'svg.mermaid'(el, text) {
const sourceEl = el.querySelector('text.source');
if (!sourceEl) return false;
return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``;
},
'svg.mermaid style, svg.mermaid g'(el, text) {
// We don't want to include the content of these elements in the copied text.
return '';
},
},
MathFilter: {
'pre.code.math[data-math-style=display]'(el, text) {
return `\`\`\`math\n${text.trim()}\n\`\`\``;
},
'code.code.math[data-math-style=inline]'(el, text) {
return `$\`${text}\`$`;
},
'span.katex-display span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
},
'span.katex-mathml'(el) {
const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
if (!mathAnnotation) return false;
return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
},
'span.katex-html'(el) {
// We don't want to include the content of this element in the copied text.
return '';
},
'annotation[encoding="application/x-tex"]'(el, text) {
return text.trim();
},
},
SanitizationFilter: {
'a[name]:not([href]):empty'(el) {
return el.outerHTML;
},
dl(el, text) {
let lines = text
.replace(/\n\n/g, '\n')
.trim()
.split('\n');
// Add two spaces to the front of subsequent list items lines,
// or leave the line entirely blank.
lines = lines.map(l => {
const line = l.trim();
if (line.length === 0) return '';
return ` ${line}`;
});
return `<dl>\n${lines.join('\n')}\n</dl>\n`;
},
'dt, dd, summary, details'(el, text) {
const tag = el.nodeName.toLowerCase();
return `<${tag}>${text}</${tag}>\n`;
},
'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) {
const tag = el.nodeName.toLowerCase();
return `<${tag}>${text}</${tag}>`;
},
},
SyntaxHighlightFilter: {
'pre.code.highlight'(el, t) {
const text = t.trimRight();
let lang = el.getAttribute('lang');
if (!lang || lang === 'plaintext') {
lang = '';
}
// Prefixes lines with 4 spaces if the code contains triple backticks
if (lang === '' && text.match(/^```/gm)) {
return text
.split('\n')
.map(l => {
const line = l.trim();
if (line.length === 0) return '';
return ` ${line}`;
})
.join('\n');
}
return `\`\`\`${lang}\n${text}\n\`\`\``;
},
'pre > code'(el, text) {
// Don't wrap code blocks in ``
return text;
},
},
MarkdownFilter: {
br(el) {
// Two spaces at the end of a line are turned into a BR
return ' ';
},
code(el, text) {
let backtickCount = 1;
const backtickMatch = text.match(/`+/);
if (backtickMatch) {
backtickCount = backtickMatch[0].length + 1;
}
const backticks = Array(backtickCount + 1).join('`');
const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
},
blockquote(el, text) {
return text
.trim()
.split('\n')
.map(s => `> ${s}`.trim())
.join('\n');
},
img(el) {
const imageSrc = el.src;
const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
return `![${el.getAttribute('alt')}](${imageUrl})`;
},
'a.anchor'(el, text) {
// Don't render a Markdown link for the anchor link inside a heading
return text;
},
a(el, text) {
return `[${text}](${el.getAttribute('href')})`;
},
li(el, text) {
const lines = text.trim().split('\n');
const firstLine = `- ${lines.shift()}`;
// Add four spaces to the front of subsequent list items lines,
// or leave the line entirely blank.
const nextLines = lines.map(s => {
if (s.trim().length === 0) return '';
return ` ${s}`;
});
return `${firstLine}\n${nextLines.join('\n')}`;
},
ul(el, text) {
return text;
},
ol(el, text) {
// LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists.
return text.replace(/^- /gm, '1. ');
},
h1(el, text) {
return `# ${text.trim()}\n`;
},
h2(el, text) {
return `## ${text.trim()}\n`;
},
h3(el, text) {
return `### ${text.trim()}\n`;
},
h4(el, text) {
return `#### ${text.trim()}\n`;
},
h5(el, text) {
return `##### ${text.trim()}\n`;
},
h6(el, text) {
return `###### ${text.trim()}\n`;
},
strong(el, text) {
return `**${text}**`;
},
em(el, text) {
return `_${text}_`;
},
del(el, text) {
return `~~${text}~~`;
},
hr(el) {
// extra leading \n is to ensure that there is a blank line between
// a list followed by an hr, otherwise this breaks old redcarpet rendering
return '\n-----\n';
},
p(el, text) {
return `${text.trim()}\n`;
},
table(el) {
const theadEl = el.querySelector('thead');
const tbodyEl = el.querySelector('tbody');
if (!theadEl || !tbodyEl) return false;
const theadText = CopyAsGFM.nodeToGFM(theadEl);
const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
return [theadText, tbodyText].join('\n');
},
thead(el, text) {
const cells = _.map(el.querySelectorAll('th'), cell => {
let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
let before = '';
let after = '';
const alignment = cell.align || cell.style.textAlign;
switch (alignment) {
case 'center':
before = ':';
after = ':';
chars -= 2;
break;
case 'right':
after = ':';
chars -= 1;
break;
default:
break;
}
chars = Math.max(chars, 3);
const middle = Array(chars + 1).join('-');
return before + middle + after;
});
const separatorRow = `|${cells.join('|')}|`;
return [text, separatorRow].join('\n');
},
tr(el) {
const cellEls = el.querySelectorAll('td, th');
if (cellEls.length === 0) return false;
const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
return `| ${cells.join(' | ')} |`;
},
},
};
import { DOMParser } from 'prosemirror-model';
import { getSelectedFragment } from '~/lib/utils/common_utils';
import schema from './schema';
import markdownSerializer from './serializer';
export class CopyAsGFM {
constructor() {
@ -347,8 +35,13 @@ export class CopyAsGFM {
e.preventDefault();
e.stopPropagation();
const div = document.createElement('div');
div.appendChild(el.cloneNode(true));
const html = div.innerHTML;
clipboardData.setData('text/plain', el.textContent);
clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
clipboardData.setData('text/html', html);
}
static pasteGFM(e) {
@ -361,7 +54,7 @@ export class CopyAsGFM {
e.preventDefault();
window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
window.gl.utils.insertText(e.target, textBefore => {
// If the text before the cursor contains an odd number of backticks,
// we are either inside an inline code span that starts with 1 backtick
// or a code block that starts with 3 backticks.
@ -443,75 +136,12 @@ export class CopyAsGFM {
return codeElement;
}
static nodeToGFM(node, respectWhitespaceParam = false) {
if (node.nodeType === Node.COMMENT_NODE) {
return '';
}
static nodeToGFM(node) {
const wrapEl = document.createElement('div');
wrapEl.appendChild(node.cloneNode(true));
const doc = DOMParser.fromSchema(schema).parse(wrapEl);
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent;
}
const respectWhitespace =
respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
const text = this.innerGFM(node, respectWhitespace);
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
return text;
}
for (const filter in gfmRules) {
const rules = gfmRules[filter];
for (const selector in rules) {
const func = rules[selector];
if (!nodeMatchesSelector(node, selector)) continue;
let result;
if (func.length === 2) {
// if `func` takes 2 arguments, it depends on text.
// if there is no text, we don't need to generate GFM for this node.
if (text.length === 0) continue;
result = func(node, text);
} else {
result = func(node);
}
if (result === false) continue;
return result;
}
}
return text;
}
static innerGFM(parentNode, respectWhitespace = false) {
const nodes = parentNode.childNodes;
const clonedParentNode = parentNode.cloneNode(true);
const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
const clonedNode = clonedNodes[i];
const text = this.nodeToGFM(node, respectWhitespace);
// `clonedNode.replaceWith(text)` is not yet widely supported
clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
}
let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
if (!respectWhitespace) {
nodeText = nodeText.trim();
}
return nodeText;
return markdownSerializer.serialize(doc);
}
}

View file

@ -0,0 +1,106 @@
import Doc from './nodes/doc';
import Paragraph from './nodes/paragraph';
import Text from './nodes/text';
import Blockquote from './nodes/blockquote';
import CodeBlock from './nodes/code_block';
import HardBreak from './nodes/hard_break';
import Heading from './nodes/heading';
import HorizontalRule from './nodes/horizontal_rule';
import Image from './nodes/image';
import Table from './nodes/table';
import TableHead from './nodes/table_head';
import TableBody from './nodes/table_body';
import TableHeaderRow from './nodes/table_header_row';
import TableRow from './nodes/table_row';
import TableCell from './nodes/table_cell';
import Emoji from './nodes/emoji';
import Reference from './nodes/reference';
import TableOfContents from './nodes/table_of_contents';
import Video from './nodes/video';
import BulletList from './nodes/bullet_list';
import OrderedList from './nodes/ordered_list';
import ListItem from './nodes/list_item';
import DescriptionList from './nodes/description_list';
import DescriptionTerm from './nodes/description_term';
import DescriptionDetails from './nodes/description_details';
import TaskList from './nodes/task_list';
import OrderedTaskList from './nodes/ordered_task_list';
import TaskListItem from './nodes/task_list_item';
import Summary from './nodes/summary';
import Details from './nodes/details';
import Bold from './marks/bold';
import Italic from './marks/italic';
import Strike from './marks/strike';
import InlineDiff from './marks/inline_diff';
import Link from './marks/link';
import Code from './marks/code';
import MathMark from './marks/math';
import InlineHTML from './marks/inline_html';
// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform
// GitLab Flavored Markdown (GFM) to HTML.
// The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard.
// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
// from GFM should have a node or mark here.
// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
export default [
new Doc(),
new Paragraph(),
new Text(),
new Blockquote(),
new CodeBlock(),
new HardBreak(),
new Heading({ maxLevel: 6 }),
new HorizontalRule(),
new Image(),
new Table(),
new TableHead(),
new TableBody(),
new TableHeaderRow(),
new TableRow(),
new TableCell(),
new Emoji(),
new Reference(),
new TableOfContents(),
new Video(),
new BulletList(),
new OrderedList(),
new ListItem(),
new DescriptionList(),
new DescriptionTerm(),
new DescriptionDetails(),
new TaskList(),
new OrderedTaskList(),
new TaskListItem(),
new Summary(),
new Details(),
new Bold(),
new Italic(),
new Strike(),
new InlineDiff(),
new Link(),
new Code(),
new MathMark(),
new InlineHTML(),
];

View file

@ -0,0 +1,24 @@
import { Schema } from 'prosemirror-model';
import editorExtensions from './editor_extensions';
const nodes = editorExtensions
.filter(extension => extension.type === 'node')
.reduce(
(ns, { name, schema }) => ({
...ns,
[name]: schema,
}),
{},
);
const marks = editorExtensions
.filter(extension => extension.type === 'mark')
.reduce(
(ms, { name, schema }) => ({
...ms,
[name]: schema,
}),
{},
);
export default new Schema({ nodes, marks });

View file

@ -0,0 +1,24 @@
import { MarkdownSerializer } from 'prosemirror-markdown';
import editorExtensions from './editor_extensions';
const nodes = editorExtensions
.filter(extension => extension.type === 'node')
.reduce(
(ns, { name, toMarkdown }) => ({
...ns,
[name]: toMarkdown,
}),
{},
);
const marks = editorExtensions
.filter(extension => extension.type === 'mark')
.reduce(
(ms, { name, toMarkdown }) => ({
...ms,
[name]: toMarkdown,
}),
{},
);
export default new MarkdownSerializer(nodes, marks);

View file

@ -1,6 +1,5 @@
import $ from 'jquery';
import Mousetrap from 'mousetrap';
import _ from 'underscore';
import Sidebar from '../../right_sidebar';
import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
@ -63,18 +62,18 @@ export default class ShortcutsIssuable extends Shortcuts {
}
const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
const selected = CopyAsGFM.nodeToGFM(el);
const blockquoteEl = document.createElement('blockquote');
blockquoteEl.appendChild(el);
const text = CopyAsGFM.nodeToGFM(blockquoteEl);
if (selected.trim() === '') {
if (text.trim() === '') {
return false;
}
const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`);
// If replyField already has some content, add a newline before our quote
const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
$replyField
.val((a, current) => `${current}${separator}${quote.join('')}\n`)
.val((a, current) => `${current}${separator}${text}\n\n`)
.trigger('input')
.trigger('change');

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,9 @@
require 'uri'
# Generated HTML is transformed back to GFM by:
# - app/assets/javascripts/behaviors/markdown/marks/math.js
# - app/assets/javascripts/behaviors/markdown/nodes/code_block.js
module Banzai
module Filter
# HTML filter that adds class="code math" and removes the dollar sign in $`2+2`$.

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
# frozen_string_literal: true
# Generated HTML is transformed back to GFM by app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
module Banzai
module Filter
# HTML filter that adds an anchor child element to all Headers in a

View file

@ -2,6 +2,10 @@
require 'task_list/filter'
# Generated HTML is transformed back to GFM by:
# - app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
# - app/assets/javascripts/behaviors/markdown/nodes/task_list.js
# - app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
module Banzai
module Filter
class TaskListFilter < TaskList::Filter

View file

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

View file

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

View file

@ -19,9 +19,9 @@ describe 'Copy as GFM', :js do
visit project_issue_path(@project, @feat.issue)
end
# The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML.
# The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js consequently convert that same HTML to GFM.
# To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle
# The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform GitLab Flavored Markdown (GFM) to HTML.
# The nodes and marks referenced in app/assets/javascripts/behaviors/markdown/editor_extensions.js consequently transform that same HTML to GFM.
# To make sure these filters and nodes/marks are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle
# by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper.
# These are all in a single `it` for performance reasons.
@ -35,12 +35,15 @@ describe 'Copy as GFM', :js do
verify(
'a real world example from the gitlab-ce README',
<<-GFM.strip_heredoc
<<~GFM
# GitLab
[![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master)
[![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby)
[![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq)
[![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42)
## Canonical source
@ -51,27 +54,31 @@ describe 'Copy as GFM', :js do
To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/).
- Manage Git repositories with fine grained access controls that keep your code secure
* Manage Git repositories with fine grained access controls that keep your code secure
- Perform code reviews and enhance collaboration with merge requests
* Perform code reviews and enhance collaboration with merge requests
- Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications
* Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications
- Each project can also have an issue tracker, issue board, and a wiki
* Each project can also have an issue tracker, issue board, and a wiki
- Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises
* Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises
- Completely free and open source (MIT Expat license)
* Completely free and open source (MIT Expat license)
GFM
)
aggregate_failures('an accidentally selected empty element') do
gfm = '# Heading1'
html = <<-HTML.strip_heredoc
html = <<~HTML
<h1>Heading1</h1>
<h2></h2>
<blockquote></blockquote>
<pre class="code highlight"></pre>
HTML
output_gfm = html_to_gfm(html)
@ -81,7 +88,7 @@ describe 'Copy as GFM', :js do
aggregate_failures('an accidentally selected other element') do
gfm = 'Test comment with **Markdown!**'
html = <<-HTML.strip_heredoc
html = <<~HTML
<li class="note">
<div class="md">
<p>
@ -107,10 +114,17 @@ describe 'Copy as GFM', :js do
verify(
'TaskListFilter',
'- [ ] Unchecked task',
'- [x] Checked task',
'1. [ ] Unchecked numbered task',
'1. [x] Checked numbered task'
<<~GFM,
* [ ] Unchecked task
* [x] Checked task
GFM
<<~GFM
1. [ ] Unchecked ordered task
1. [x] Checked ordered task
GFM
)
verify(
@ -139,7 +153,16 @@ describe 'Copy as GFM', :js do
verify(
'TableOfContentsFilter',
'[[_TOC_]]'
<<~GFM,
[[_TOC_]]
# Heading 1
## Heading 2
GFM
pipeline: :wiki,
project_wiki: @project.wiki
)
verify(
@ -166,7 +189,7 @@ describe 'Copy as GFM', :js do
'$`c = \pm\sqrt{a^2 + b^2}`$',
# math block
<<-GFM.strip_heredoc
<<~GFM
```math
c = \pm\sqrt{a^2 + b^2}
```
@ -176,7 +199,7 @@ describe 'Copy as GFM', :js do
aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do
gfm = '$`c = \pm\sqrt{a^2 + b^2}`$'
html = <<-HTML.strip_heredoc
html = <<~HTML
<span class="katex">
<span class="katex-mathml">
<math>
@ -287,7 +310,7 @@ describe 'Copy as GFM', :js do
verify(
'MermaidFilter: mermaid as converted from GFM to HTML',
<<-GFM.strip_heredoc
<<~GFM
```mermaid
graph TD;
A-->B;
@ -296,14 +319,14 @@ describe 'Copy as GFM', :js do
)
aggregate_failures('MermaidFilter: mermaid as transformed from HTML to SVG') do
gfm = <<-GFM.strip_heredoc
gfm = <<~GFM
```mermaid
graph TD;
A-->B;
```
GFM
html = <<-HTML.strip_heredoc
html = <<~HTML
<svg id="mermaidChart1" xmlns="http://www.w3.org/2000/svg" height="100%" viewBox="0 0 87.234375 174" style="max-width:87.234375px;" class="mermaid">
<style>
.mermaid {
@ -371,8 +394,7 @@ describe 'Copy as GFM', :js do
</g>
</g>
<text class="source" display="none">graph TD;
A--&gt;B;
</text>
A--&gt;B;</text>
</svg>
HTML
@ -383,11 +405,18 @@ describe 'Copy as GFM', :js do
verify(
'SanitizationFilter',
<<-GFM.strip_heredoc
<<~GFM
<sub>sub</sub>
<dl>
<dt>dt</dt>
<dt>dt</dt>
<dd>dd</dd>
<dd>dd</dd>
<dt>dt</dt>
<dt>dt</dt>
<dd>dd</dd>
<dd>dd</dd>
</dl>
@ -399,30 +428,26 @@ describe 'Copy as GFM', :js do
<var>var</var>
<ruby>ruby</ruby>
<abbr title="HyperText &quot;Markup&quot; Language">HTML</abbr>
<rt>rt</rt>
<details>
<summary>summary></summary>
<rp>rp</rp>
<abbr>abbr</abbr>
<summary>summary</summary>
<details>details</details>
details
</details>
GFM
)
verify(
'SanitizationFilter',
<<-GFM.strip_heredoc,
<<~GFM,
```
Plain text
```
GFM
<<-GFM.strip_heredoc,
<<~GFM,
```ruby
def foo
bar
@ -430,11 +455,9 @@ describe 'Copy as GFM', :js do
```
GFM
<<-GFM.strip_heredoc
<<~GFM
Foo
This is an example of GFM
```js
Code goes here
```
@ -452,9 +475,8 @@ describe 'Copy as GFM', :js do
'> Quote',
# multiline quote
<<-GFM.strip_heredoc,
> Multiline
> Quote
<<~GFM,
> Multiline Quote
>
> With multiple paragraphs
GFM
@ -465,48 +487,58 @@ describe 'Copy as GFM', :js do
'[Link](https://example.com)',
'- List item',
<<~GFM,
* List item
* List item 2
GFM
# multiline list item
<<-GFM.strip_heredoc,
- Multiline
List item
<<~GFM,
* Multiline
List item
GFM
# nested lists
<<-GFM.strip_heredoc,
- Nested
<<~GFM,
* Nested
- Lists
* Lists
GFM
# list with blockquote
<<-GFM.strip_heredoc,
- List
<<~GFM,
* List
> Blockquote
> Blockquote
GFM
'1. Numbered list item',
<<~GFM,
1. Ordered list item
# multiline numbered list item
<<-GFM.strip_heredoc,
1. Ordered list item 2
GFM
# multiline ordered list item
<<~GFM,
1. Multiline
Numbered list item
Ordered list item
GFM
# nested numbered list
<<-GFM.strip_heredoc,
# nested ordered list
<<~GFM,
1. Nested
1. Numbered lists
1. Ordered lists
GFM
# list item followed by an HR
<<-GFM.strip_heredoc,
- list item
<<~GFM,
* list item
-----
---
GFM
'# Heading',
@ -518,14 +550,14 @@ describe 'Copy as GFM', :js do
'**Bold**',
'_Italics_',
'*Italics*',
'~~Strikethrough~~',
'-----',
'---',
# table
<<-GFM.strip_heredoc,
<<~GFM,
| Centered | Right | Left |
|:--------:|------:|------|
| Foo | Bar | **Baz** |
@ -533,9 +565,9 @@ describe 'Copy as GFM', :js do
GFM
# table with empty heading
<<-GFM.strip_heredoc,
<<~GFM,
| | x | y |
|---|---|---|
|--|---|---|
| a | 1 | 0 |
| b | 0 | 1 |
GFM
@ -545,9 +577,11 @@ describe 'Copy as GFM', :js do
alias_method :gfm_to_html, :markdown
def verify(label, *gfms)
markdown_options = gfms.extract_options!
aggregate_failures(label) do
gfms.each do |gfm|
html = gfm_to_html(gfm).gsub(/\A&#x000A;|&#x000A;\z/, '')
html = gfm_to_html(gfm, markdown_options).gsub(/\A&#x000A;|&#x000A;\z/, '')
output_gfm = html_to_gfm(html)
expect(output_gfm.strip).to eq(gfm.strip)
end
@ -594,7 +628,7 @@ describe 'Copy as GFM', :js do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
<<-GFM.strip_heredoc,
<<~GFM,
```ruby
raise RuntimeError, "System commands must be given as an array of strings"
end
@ -627,7 +661,7 @@ describe 'Copy as GFM', :js do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
<<-GFM.strip_heredoc,
<<~GFM,
```ruby
unless cmd.is_a?(Array)
raise "System commands must be given as an array of strings"
@ -645,7 +679,7 @@ describe 'Copy as GFM', :js do
verify(
'[id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_8_8"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_9_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_9"], [id="2f6fcd96b88b36ce98c38da085c795a27d92a3dd_10_10"]',
<<-GFM.strip_heredoc,
<<~GFM,
```ruby
unless cmd.is_a?(Array)
raise RuntimeError, "System commands must be given as an array of strings"
@ -691,7 +725,7 @@ describe 'Copy as GFM', :js do
verify(
'.line[id="LC9"], .line[id="LC10"]',
<<-GFM.strip_heredoc,
<<~GFM,
```ruby
raise RuntimeError, "System commands must be given as an array of strings"
end
@ -733,7 +767,7 @@ describe 'Copy as GFM', :js do
verify(
'.line[id="LC27"], .line[id="LC28"]',
<<-GFM.strip_heredoc,
<<~GFM,
```json
"bio": null,
"skype": "",
@ -752,7 +786,7 @@ describe 'Copy as GFM', :js do
end
def html_for_selector(selector)
js = <<-JS.strip_heredoc
js = <<~JS
(function(selector) {
var els = document.querySelectorAll(selector);
var htmls = [].slice.call(els).map(function(el) { return el.outerHTML; });
@ -763,7 +797,7 @@ describe 'Copy as GFM', :js do
end
def html_to_gfm(html, transformer = 'transformGFMSelection', target: nil)
js = <<-JS.strip_heredoc
js = <<~JS
(function(html) {
var transformer = window.CopyAsGFM[#{transformer.inspect}];

View file

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