diff --git a/Gruntfile.js b/Gruntfile.js index 3090606d4a..298d813a38 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -589,7 +589,8 @@ var _ = require('lodash'), 'bower_components/nanoscroller/bin/javascripts/jquery.nanoscroller.js', 'core/shared/lib/showdown/extensions/ghostimagepreview.js', - 'core/shared/lib/showdown/extensions/ghostgfm.js' + 'core/shared/lib/showdown/extensions/ghostgfm.js', + 'core/shared/lib/showdown/extensions/ghostfootnotes.js' ] }, @@ -624,7 +625,8 @@ var _ = require('lodash'), 'bower_components/nanoscroller/bin/javascripts/jquery.nanoscroller.js', 'core/shared/lib/showdown/extensions/ghostimagepreview.js', - 'core/shared/lib/showdown/extensions/ghostgfm.js' + 'core/shared/lib/showdown/extensions/ghostgfm.js', + 'core/shared/lib/showdown/extensions/ghostfootnotes.js' ] } }, diff --git a/core/client/helpers/gh-format-markdown.js b/core/client/helpers/gh-format-markdown.js index 29419b798e..69be7fc13d 100644 --- a/core/client/helpers/gh-format-markdown.js +++ b/core/client/helpers/gh-format-markdown.js @@ -4,7 +4,7 @@ import cajaSanitizers from 'ghost/utils/caja-sanitizers'; var showdown, formatMarkdown; -showdown = new Showdown.converter({extensions: ['ghostimagepreview', 'ghostgfm']}); +showdown = new Showdown.converter({extensions: ['ghostimagepreview', 'ghostgfm', 'footnotes']}); formatMarkdown = Ember.Handlebars.makeBoundHelper(function (markdown) { var escapedhtml = ''; diff --git a/core/server/models/post.js b/core/server/models/post.js index 77a3ba6eed..8a1483b142 100644 --- a/core/server/models/post.js +++ b/core/server/models/post.js @@ -5,7 +5,8 @@ var _ = require('lodash'), errors = require('../errors'), Showdown = require('showdown-ghost'), ghostgfm = require('../../shared/lib/showdown/extensions/ghostgfm'), - converter = new Showdown.converter({extensions: [ghostgfm]}), + ghostfootnotes = require('../../shared/lib/showdown/extensions/ghostfootnotes'), + converter = new Showdown.converter({extensions: [ghostgfm, ghostfootnotes]}), ghostBookshelf = require('./base'), xmlrpc = require('../xmlrpc'), sitemap = require('../data/sitemap'), diff --git a/core/shared/lib/showdown/extensions/ghostfootnotes.js b/core/shared/lib/showdown/extensions/ghostfootnotes.js new file mode 100644 index 0000000000..dcb56f3c36 --- /dev/null +++ b/core/shared/lib/showdown/extensions/ghostfootnotes.js @@ -0,0 +1,111 @@ +/* jshint node:true, browser:true */ + +// Adds footnote syntax as per Markdown Extra: +// +// https://michelf.ca/projects/php-markdown/extra/#footnotes +// +// That's some text with a footnote.[^1] +// +// [^1]: And that's the footnote. +// +// That's the second paragraph. +// +// Also supports [^n] if you don't want to worry about preserving +// the footnote order yourself. + +function replaceInlineFootnotes(text) { + // Inline footnotes e.g. "foo[^1]" + var inlineRegex = /(?!^)\[\^(\d|n)\]/gim, + i = 0; + + return text.replace(inlineRegex, function (match, n) { + // We allow both automatic and manual footnote numbering + if (n === 'n') { + n = i + 1; + } + + var s = '' + + '' + n + '' + + ''; + i += 1; + return s; + }); +} + +function replaceEndFootnotes(text) { + // Expanded footnotes at the end e.g. "[^1]: cool stuff" + var endRegex = /\[\^(\d|n)\]: ([\s\S]*?)$(?! )/gim, + m = text.match(endRegex), + total = m ? m.length : 0, + i = 0; + + return text.replace(endRegex, function (match, n, content) { + if (n === 'n') { + n = i + 1; + } + + content = content.replace(/\n /g, '
'); + + var s = '
  • ' + + '

    ' + content + ' ' + + '

    ' + + '
  • '; + + if (i === 0) { + s = '
      ' + s; + } + + if (i === total - 1) { + s = s + '
    '; + } + + i += 1; + return s; + }); +} + +(function () { + var footnotes = function () { + return [ + { + type: 'lang', + filter: function (text) { + var preExtractions = {}, + hashID = 0; + + function hashId() { + return hashID += 1; + } + + // Extract pre blocks + text = text.replace(/```[\s\S]*?\n```/gim, function (x) { + var hash = hashId(); + preExtractions[hash] = x; + return '{gfm-js-extract-pre-' + hash + '}'; + }, 'm'); + + console.log(text); + text = replaceInlineFootnotes(text); + text = replaceEndFootnotes(text); + + // replace extractions + text = text.replace(/\{gfm-js-extract-pre-([0-9]+)\}/gm, function (x, y) { + return preExtractions[y]; + }); + + return text; + } + } + ]; + }; + + // Client-side export + if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { + window.Showdown.extensions.footnotes = footnotes; + } + // Server-side export + if (typeof module !== 'undefined') { + module.exports = footnotes; + } +}()); diff --git a/core/test/unit/showdown_client_integrated_spec.js b/core/test/unit/showdown_client_integrated_spec.js index 2f74a38f9d..04f180896c 100644 --- a/core/test/unit/showdown_client_integrated_spec.js +++ b/core/test/unit/showdown_client_integrated_spec.js @@ -12,8 +12,9 @@ var should = require('should'), Showdown = require('showdown-ghost'), ghostgfm = require('../../shared/lib/showdown/extensions/ghostgfm'), ghostimagepreview = require('../../shared/lib/showdown/extensions/ghostimagepreview'), + ghostfootnotes = require('../../shared/lib/showdown/extensions/ghostfootnotes'), - converter = new Showdown.converter({extensions: [ghostimagepreview, ghostgfm]}); + converter = new Showdown.converter({extensions: [ghostimagepreview, ghostgfm, ghostfootnotes]}); // To stop jshint complaining should.equal(true, true); @@ -512,6 +513,25 @@ describe('Showdown client side converter', function () { }); }); + it('should treat ![^n] as footnote unless it occurs on a new line', function () { + var testPhrases = [ + { + input: 'Foo![^1](bar)', + output: '

    Foo!1(bar)

    ' + }, + + { + input: '![^1](bar)', + output: '
    Add image of ^1
    ' + } + ]; + + testPhrases.forEach(function (testPhrase) { + var processedMarkup = converter.makeHtml(testPhrase.input); + processedMarkup.should.match(testPhrase.output); + }); + }); + // Waiting for showdown typography to be updated // it('should correctly convert quotes to curly quotes', function () { // var testPhrases = [ diff --git a/core/test/unit/showdown_footnotes_spec.js b/core/test/unit/showdown_footnotes_spec.js new file mode 100644 index 0000000000..298e86c1fa --- /dev/null +++ b/core/test/unit/showdown_footnotes_spec.js @@ -0,0 +1,86 @@ +/** + * Tests the footnotes extension for showdown + * + */ + +/*globals describe, it */ +/*jshint expr:true*/ +var should = require('should'), + + // Stuff we are testing + ghostfootnotes = require('../../shared/lib/showdown/extensions/ghostfootnotes'); + +// To stop jshint complaining +should.equal(true, true); + +function _ExecuteExtension(ext, text) { + if (ext.regex) { + var re = new RegExp(ext.regex, 'g'); + return text.replace(re, ext.replace); + } else if (ext.filter) { + return ext.filter(text); + } +} + +function _ConvertPhrase(testPhrase) { + return ghostfootnotes().reduce(function (text, ext) { + return _ExecuteExtension(ext, text); + }, testPhrase); +} + +describe('Ghost footnotes showdown extension', function () { + /*jslint regexp: true */ + + it('should export an array of methods for processing', function () { + ghostfootnotes.should.be.a.function; + ghostfootnotes().should.be.an.Array; + + ghostfootnotes().forEach(function (processor) { + processor.should.be.an.Object; + processor.should.have.property('type'); + processor.type.should.be.a.String; + }); + }); + + it('should replace inline footnotes with the right html', function () { + var testPhrase = { + input: 'foo_bar[^1]', + output: /1<\/a><\/sup>/ + }, processedMarkup = _ConvertPhrase(testPhrase.input); + + processedMarkup.should.match(testPhrase.output); + }); + + it('should replace end footnotes with the right html', function () { + var testPhrase = { + input: '[^1]: foo bar', + output: /
    1. foo bar ↩<\/a><\/p><\/li><\/ol><\/div>/ + }, processedMarkup = _ConvertPhrase(testPhrase.input); + + processedMarkup.should.match(testPhrase.output); + }); + + it('should number multiple footnotes correctly', function () { + var testPhrase = { + input: 'foo[^1] bar[^n] etc[^2]', + output: /foo1<\/a><\/sup> bar2<\/a><\/sup> etc2<\/a><\/sup>/ + }, processedMarkup = _ConvertPhrase(testPhrase.input); + + processedMarkup.should.match(testPhrase.output); + }); + + it('should put everything together', function () { + // Tests for some interaction bugs between components e.g. + // confusing the end form and the inline form + var testPhrase = { + input: 'foo bar[^1] is a very[^n] foo bar[^1]\n' + + '[^n]: a metasyntactic variable\n' + + '[^n]: this is hard to measure', + output: 'foo bar1 is a very2 foo bar1\n' + + '

      1. a metasyntactic variable

      2. \n' + + '
      3. this is hard to measure

      ' + }, processedMarkup = _ConvertPhrase(testPhrase.input); + + processedMarkup.should.match(testPhrase.output); + }); +});