Skip to content

Commit

Permalink
Adding Github Flavored Markdown support
Browse files Browse the repository at this point in the history
closes TryGhost#422, issue TryGhost#295

- Added GFM mode to codemirror
- Took the github.js extension for Showdown and added all useful behaviour
- Now supports strikethrough, line breaking and
  multiple underscores, and auto linking urls & emails without breaking
  definition urls
- Also added definition url handling in preparation for TryGhost#295
- Added unit tests for the extentions individually and integrated with
  showdown
  • Loading branch information
ErisDS committed Aug 29, 2013
1 parent c11e30a commit f318d16
Show file tree
Hide file tree
Showing 15 changed files with 919 additions and 44 deletions.
12 changes: 10 additions & 2 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,16 @@ var path = require('path'),
src: ['core/test/unit/**/api*_spec.js']
},

frontend: {
src: ['core/test/unit/**/frontend*_spec.js']
client: {
src: ['core/test/unit/**/client*_spec.js']
},

server: {
src: ['core/test/unit/**/server*_spec.js']
},

shared: {
src: ['core/test/unit/**/shared*_spec.js']
},

perm: {
Expand Down
59 changes: 59 additions & 0 deletions core/client/assets/vendor/codemirror/addon/mode/overlay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Utility function that allows modes to be combined. The mode given
// as the base argument takes care of most of the normal mode
// functionality, but a second (typically simple) mode is used, which
// can override the style of text. Both modes get to parse all of the
// text, but when both assign a non-null style to a piece of code, the
// overlay wins, unless the combine argument was true, in which case
// the styles are combined.

// overlayParser is the old, deprecated name
CodeMirror.overlayMode = CodeMirror.overlayParser = function(base, overlay, combine) {
return {
startState: function() {
return {
base: CodeMirror.startState(base),
overlay: CodeMirror.startState(overlay),
basePos: 0, baseCur: null,
overlayPos: 0, overlayCur: null
};
},
copyState: function(state) {
return {
base: CodeMirror.copyState(base, state.base),
overlay: CodeMirror.copyState(overlay, state.overlay),
basePos: state.basePos, baseCur: null,
overlayPos: state.overlayPos, overlayCur: null
};
},

token: function(stream, state) {
if (stream.start == state.basePos) {
state.baseCur = base.token(stream, state.base);
state.basePos = stream.pos;
}
if (stream.start == state.overlayPos) {
stream.pos = stream.start;
state.overlayCur = overlay.token(stream, state.overlay);
state.overlayPos = stream.pos;
}
stream.pos = Math.min(state.basePos, state.overlayPos);
if (stream.eol()) state.basePos = state.overlayPos = 0;

if (state.overlayCur == null) return state.baseCur;
if (state.baseCur != null && combine) return state.baseCur + " " + state.overlayCur;
else return state.overlayCur;
},

indent: base.indent && function(state, textAfter) {
return base.indent(state.base, textAfter);
},
electricChars: base.electricChars,

innerMode: function(state) { return {state: state.base, mode: base}; },

blankLine: function(state) {
if (base.blankLine) base.blankLine(state.base);
if (overlay.blankLine) overlay.blankLine(state.overlay);
}
};
};
96 changes: 96 additions & 0 deletions core/client/assets/vendor/codemirror/mode/gfm/gfm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
CodeMirror.defineMode("gfm", function(config) {
var codeDepth = 0;
function blankLine(state) {
state.code = false;
return null;
}
var gfmOverlay = {
startState: function() {
return {
code: false,
codeBlock: false,
ateSpace: false
};
},
copyState: function(s) {
return {
code: s.code,
codeBlock: s.codeBlock,
ateSpace: s.ateSpace
};
},
token: function(stream, state) {
// Hack to prevent formatting override inside code blocks (block and inline)
if (state.codeBlock) {
if (stream.match(/^```/)) {
state.codeBlock = false;
return null;
}
stream.skipToEnd();
return null;
}
if (stream.sol()) {
state.code = false;
}
if (stream.sol() && stream.match(/^```/)) {
stream.skipToEnd();
state.codeBlock = true;
return null;
}
// If this block is changed, it may need to be updated in Markdown mode
if (stream.peek() === '`') {
stream.next();
var before = stream.pos;
stream.eatWhile('`');
var difference = 1 + stream.pos - before;
if (!state.code) {
codeDepth = difference;
state.code = true;
} else {
if (difference === codeDepth) { // Must be exact
state.code = false;
}
}
return null;
} else if (state.code) {
stream.next();
return null;
}
// Check if space. If so, links can be formatted later on
if (stream.eatSpace()) {
state.ateSpace = true;
return null;
}
if (stream.sol() || state.ateSpace) {
state.ateSpace = false;
if(stream.match(/^(?:[a-zA-Z0-9\-_]+\/)?(?:[a-zA-Z0-9\-_]+@)?(?:[a-f0-9]{7,40}\b)/)) {
// User/Project@SHA
// User@SHA
// SHA
return "link";
} else if (stream.match(/^(?:[a-zA-Z0-9\-_]+\/)?(?:[a-zA-Z0-9\-_]+)?#[0-9]+\b/)) {
// User/Project#Num
// User#Num
// #Num
return "link";
}
}
if (stream.match(/^((?:[a-z][\w-]+:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\([^\s()<>]*\))+(?:\([^\s()<>]*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’]))/i)) {
// URLs
// Taken from http://daringfireball.net/2010/07/improved_regex_for_matching_urls
// And then (issue #1160) simplified to make it not crash the Chrome Regexp engine
return "link";
}
stream.next();
return null;
},
blankLine: blankLine
};
CodeMirror.defineMIME("gfmBase", {
name: "markdown",
underscoresBreakWords: false,
taskLists: true,
fencedCodeBlocks: true
});
return CodeMirror.overlayMode(CodeMirror.getMode(config, "gfmBase"), gfmOverlay);
}, "markdown");
74 changes: 74 additions & 0 deletions core/client/assets/vendor/codemirror/mode/gfm/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>CodeMirror: GFM mode</title>
<link rel="stylesheet" href="../../lib/codemirror.css">
<script src="../../lib/codemirror.js"></script>
<script src="../../addon/mode/overlay.js"></script>
<script src="../xml/xml.js"></script>
<script src="../markdown/markdown.js"></script>
<script src="gfm.js"></script>

<!-- Code block highlighting modes -->
<script src="../javascript/javascript.js"></script>
<script src="../css/css.js"></script>
<script src="../htmlmixed/htmlmixed.js"></script>
<script src="../clike/clike.js"></script>

<style type="text/css">.CodeMirror {border-top: 1px solid black; border-bottom: 1px solid black;}</style>
<link rel="stylesheet" href="../../doc/docs.css">
</head>
<body>
<h1>CodeMirror: GFM mode</h1>

<form><textarea id="code" name="code">
GitHub Flavored Markdown
========================

Everything from markdown plus GFM features:

## URL autolinking

Underscores_are_allowed_between_words.

## Fenced code blocks (and syntax highlighting)

```javascript
for (var i = 0; i &lt; items.length; i++) {
console.log(items[i], i); // log them
}
```

## Task Lists

- [ ] Incomplete task list item
- [x] **Completed** task list item

## A bit of GitHub spice

* SHA: be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2
* User@SHA ref: mojombo@be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2
* User/Project@SHA: mojombo/god@be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2
* \#Num: #1
* User/#Num: mojombo#1
* User/Project#Num: mojombo/god#1

See http://github.github.com/github-flavored-markdown/.

</textarea></form>

<script>
var editor = CodeMirror.fromTextArea(document.getElementById("code"), {
mode: 'gfm',
lineNumbers: true,
theme: "default"
});
</script>

<p>Optionally depends on other modes for properly highlighted code blocks.</p>

<p><strong>Parsing/Highlighting Tests:</strong> <a href="../../test/index.html#gfm_*">normal</a>, <a href="../../test/index.html#verbose,gfm_*">verbose</a>.</p>

</body>
</html>
112 changes: 112 additions & 0 deletions core/client/assets/vendor/codemirror/mode/gfm/test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
(function() {
var mode = CodeMirror.getMode({tabSize: 4}, "gfm");
function MT(name) { test.mode(name, mode, Array.prototype.slice.call(arguments, 1)); }

MT("emInWordAsterisk",
"foo[em *bar*]hello");

MT("emInWordUnderscore",
"foo_bar_hello");

MT("emStrongUnderscore",
"[strong __][em&strong _foo__][em _] bar");

MT("fencedCodeBlocks",
"[comment ```]",
"[comment foo]",
"",
"[comment ```]",
"bar");

MT("fencedCodeBlockModeSwitching",
"[comment ```javascript]",
"[variable foo]",
"",
"[comment ```]",
"bar");

MT("taskListAsterisk",
"[variable-2 * []] foo]", // Invalid; must have space or x between []
"[variable-2 * [ ]]bar]", // Invalid; must have space after ]
"[variable-2 * [x]]hello]", // Invalid; must have space after ]
"[variable-2 * ][meta [ ]]][variable-2 [world]]]", // Valid; tests reference style links
" [variable-3 * ][property [x]]][variable-3 foo]"); // Valid; can be nested

MT("taskListPlus",
"[variable-2 + []] foo]", // Invalid; must have space or x between []
"[variable-2 + [ ]]bar]", // Invalid; must have space after ]
"[variable-2 + [x]]hello]", // Invalid; must have space after ]
"[variable-2 + ][meta [ ]]][variable-2 [world]]]", // Valid; tests reference style links
" [variable-3 + ][property [x]]][variable-3 foo]"); // Valid; can be nested

MT("taskListDash",
"[variable-2 - []] foo]", // Invalid; must have space or x between []
"[variable-2 - [ ]]bar]", // Invalid; must have space after ]
"[variable-2 - [x]]hello]", // Invalid; must have space after ]
"[variable-2 - ][meta [ ]]][variable-2 [world]]]", // Valid; tests reference style links
" [variable-3 - ][property [x]]][variable-3 foo]"); // Valid; can be nested

MT("taskListNumber",
"[variable-2 1. []] foo]", // Invalid; must have space or x between []
"[variable-2 2. [ ]]bar]", // Invalid; must have space after ]
"[variable-2 3. [x]]hello]", // Invalid; must have space after ]
"[variable-2 4. ][meta [ ]]][variable-2 [world]]]", // Valid; tests reference style links
" [variable-3 1. ][property [x]]][variable-3 foo]"); // Valid; can be nested

MT("SHA",
"foo [link be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2] bar");

MT("shortSHA",
"foo [link be6a8cc] bar");

MT("tooShortSHA",
"foo be6a8c bar");

MT("longSHA",
"foo be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd22 bar");

MT("badSHA",
"foo be6a8cc1c1ecfe9489fb51e4869af15a13fc2cg2 bar");

MT("userSHA",
"foo [link bar@be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2] hello");

MT("userProjectSHA",
"foo [link bar/hello@be6a8cc1c1ecfe9489fb51e4869af15a13fc2cd2] world");

MT("num",
"foo [link #1] bar");

MT("badNum",
"foo #1bar hello");

MT("userNum",
"foo [link bar#1] hello");

MT("userProjectNum",
"foo [link bar/hello#1] world");

MT("vanillaLink",
"foo [link http://www.example.com/] bar");

MT("vanillaLinkPunctuation",
"foo [link http://www.example.com/]. bar");

MT("vanillaLinkExtension",
"foo [link http://www.example.com/index.html] bar");

MT("notALink",
"[comment ```css]",
"[tag foo] {[property color][operator :][keyword black];}",
"[comment ```][link http://www.example.com/]");

MT("notALink",
"[comment ``foo `bar` http://www.example.com/``] hello");

MT("notALink",
"[comment `foo]",
"[link http://www.example.com/]",
"[comment `foo]",
"",
"[link http://www.example.com/]");
})();
12 changes: 11 additions & 1 deletion core/client/assets/vendor/showdown/extensions/ghostdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,22 @@
{
type: 'lang',
filter: function (text) {
return text.replace(/\n?!\[([^\n\]]*)\](?:\(([^\n\)]*)\))?/gi, function (match, alt, src) {
var defRegex = /^ *\[([^\]]+)\]: *<?([^\s>]+)>?(?: +["(]([^\n]+)[")])? *(?:\n+|$)/gim,
match,
defUrls = {};

while ((match = defRegex.exec(text)) !== null) {
defUrls[match[1]] = match;
}

return text.replace(/^!(?:\[([^\n\]]*)\])(?:\[([^\n\]]*)\]|\(([^\n\]]*)\))?$/gim, function (match, alt, id, src) {
var result = "";

/* regex from isURL in node-validator. Yum! */
if (src && src.match(/^(?!mailto:)(?:(?:https?|ftp):\/\/)?(?:\S+(?::\S*)?@)?(?:(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))|localhost)(?::\d{2,5})?(?:\/[^\s]*)?$/i)) {
result = '<img class="js-upload-target" src="' + src + '"/>';
} else if (id && defUrls.hasOwnProperty(id)) {
result = '<img class="js-upload-target" src="' + defUrls[id][2] + '"/>';
}
return '<section class="js-drop-zone image-uploader">' + result +
'<div class="description">Add image of <strong>' + alt + '</strong></div>' +
Expand Down
4 changes: 2 additions & 2 deletions core/client/views/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,9 @@
initMarkdown: function () {
var self = this;

this.converter = new Showdown.converter({extensions: ['ghostdown']});
this.converter = new Showdown.converter({extensions: ['ghostdown', 'github']});
this.editor = CodeMirror.fromTextArea(document.getElementById('entry-markdown'), {
mode: 'markdown',
mode: 'gfm',
tabMode: 'indent',
tabindex: "2",
lineWrapping: true,
Expand Down
Loading

0 comments on commit f318d16

Please sign in to comment.