diff options
-rw-r--r-- | Gemfile | 2 | ||||
-rw-r--r-- | lib/redmine.rb | 2 | ||||
-rw-r--r-- | lib/redmine/wiki_formatting/markdown/formatter.rb | 136 | ||||
-rw-r--r-- | lib/redmine/wiki_formatting/markdown/helper.rb | 45 | ||||
-rw-r--r-- | public/javascripts/jstoolbar/markdown.js | 194 | ||||
-rw-r--r-- | test/unit/lib/redmine/wiki_formatting/markdown_formatter.rb | 62 |
6 files changed, 441 insertions, 0 deletions
@@ -5,6 +5,8 @@ gem "jquery-rails", "~> 2.0.2" gem "coderay", "~> 1.1.0" gem "fastercsv", "~> 1.5.0", :platforms => [:mri_18, :mingw_18, :jruby] gem "builder", "3.0.0" +# TODO: upgrade to redcarpet 3.x when ruby1.8 support is dropped +gem "redcarpet", "~> 2.3.0" # Optional gem for LDAP authentication group :ldap do diff --git a/lib/redmine.rb b/lib/redmine.rb index e62014c73..187689ea1 100644 --- a/lib/redmine.rb +++ b/lib/redmine.rb @@ -267,6 +267,8 @@ end Redmine::WikiFormatting.map do |format| format.register :textile, Redmine::WikiFormatting::Textile::Formatter, Redmine::WikiFormatting::Textile::Helper + format.register :markdown, Redmine::WikiFormatting::Markdown::Formatter, Redmine::WikiFormatting::Markdown::Helper, + :label => 'Markdown (experimental)' end ActionView::Template.register_template_handler :rsb, Redmine::Views::ApiTemplateHandler diff --git a/lib/redmine/wiki_formatting/markdown/formatter.rb b/lib/redmine/wiki_formatting/markdown/formatter.rb new file mode 100644 index 000000000..19ee46693 --- /dev/null +++ b/lib/redmine/wiki_formatting/markdown/formatter.rb @@ -0,0 +1,136 @@ +# Redmine - project management software +# Copyright (C) 2006-2013 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require 'cgi' + +module Redmine + module WikiFormatting + module Markdown + class HTML < Redcarpet::Render::HTML + include ActionView::Helpers::TagHelper + + def link(link, title, content) + css = nil + unless link && link.starts_with?('/') + css = 'external' + end + content_tag('a', content.html_safe, :href => link, :title => title, :class => css) + end + + def block_code(code, language) + if language.present? + "<pre><code class=\"#{CGI.escapeHTML language} syntaxhl\">" + + Redmine::SyntaxHighlighting.highlight_by_language(code, language) + + "</code></pre>" + else + "<pre>" + CGI.escapeHTML(code) + "</pre>" + end + end + end + + class Formatter + def initialize(text) + @text = text + end + + def to_html(*args) + html = formatter.render(@text) + # restore wiki links eg. [[Foo]] + html.gsub!(%r{\[<a href="(.*?)">(.*?)</a>\]}) do + "[[#{$2}]]" + end + # restore Redmine links with double-quotes, eg. version:"1.0" + html.gsub!(/(\w):"(.+?)"/) do + "#{$1}:\"#{$2}\"" + end + html + end + + def get_section(index) + section = extract_sections(index)[1] + hash = Digest::MD5.hexdigest(section) + return section, hash + end + + def update_section(index, update, hash=nil) + t = extract_sections(index) + if hash.present? && hash != Digest::MD5.hexdigest(t[1]) + raise Redmine::WikiFormatting::StaleSectionError + end + t[1] = update unless t[1].blank? + t.reject(&:blank?).join "\n\n" + end + + def extract_sections(index) + sections = ['', '', ''] + offset = 0 + i = 0 + l = 1 + inside_pre = false + @text.split(/(^(?:.+\r?\n\r?(?:\=+|\-+)|#+.+|~~~.*)\s*$)/).each do |part| + level = nil + if part =~ /\A~{3,}(\S+)?\s*$/ + if $1 + if !inside_pre + inside_pre = true + end + else + inside_pre = !inside_pre + end + elsif inside_pre + # nop + elsif part =~ /\A(#+).+/ + level = $1.size + elsif part =~ /\A.+\r?\n\r?(\=+|\-+)\s*$/ + level = $1.include?('=') ? 1 : 2 + end + if level + i += 1 + if offset == 0 && i == index + # entering the requested section + offset = 1 + l = level + elsif offset == 1 && i > index && level <= l + # leaving the requested section + offset = 2 + end + end + sections[offset] << part + end + sections.map(&:strip) + end + + private + + def formatter + @@formatter ||= Redcarpet::Markdown.new( + Redmine::WikiFormatting::Markdown::HTML.new( + :filter_html => true, + :hard_wrap => true + ), + :autolink => true, + :fenced_code_blocks => true, + :space_after_headers => true, + :tables => true, + :strikethrough => true, + :superscript => true + ) + end + end + end + end +end diff --git a/lib/redmine/wiki_formatting/markdown/helper.rb b/lib/redmine/wiki_formatting/markdown/helper.rb new file mode 100644 index 000000000..2619cb71e --- /dev/null +++ b/lib/redmine/wiki_formatting/markdown/helper.rb @@ -0,0 +1,45 @@ +# Redmine - project management software +# Copyright (C) 2006-2013 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +module Redmine + module WikiFormatting + module Markdown + module Helper + def wikitoolbar_for(field_id) + heads_for_wiki_formatter + javascript_tag("var wikiToolbar = new jsToolBar(document.getElementById('#{field_id}')); wikiToolbar.draw();") + end + + def initial_page_content(page) + "# #{@page.pretty_title}" + end + + def heads_for_wiki_formatter + unless @heads_for_wiki_formatter_included + content_for :header_tags do + javascript_include_tag('jstoolbar/jstoolbar') + + javascript_include_tag('jstoolbar/markdown') + + javascript_include_tag("jstoolbar/lang/jstoolbar-#{current_language.to_s.downcase}") + + stylesheet_link_tag('jstoolbar') + end + @heads_for_wiki_formatter_included = true + end + end + end + end + end +end diff --git a/public/javascripts/jstoolbar/markdown.js b/public/javascripts/jstoolbar/markdown.js new file mode 100644 index 000000000..9c3b15431 --- /dev/null +++ b/public/javascripts/jstoolbar/markdown.js @@ -0,0 +1,194 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * This file is part of DotClear. + * Copyright (c) 2005 Nicolas Martin & Olivier Meunier and contributors. All + * rights reserved. + * + * DotClear is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * DotClear is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with DotClear; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + * ***** END LICENSE BLOCK ***** +*/ + +/* Modified by JP LANG for markdown formatting */ + +// strong +jsToolBar.prototype.elements.strong = { + type: 'button', + title: 'Strong', + fn: { + wiki: function() { this.singleTag('**') } + } +} + +// em +jsToolBar.prototype.elements.em = { + type: 'button', + title: 'Italic', + fn: { + wiki: function() { this.singleTag("*") } + } +} + +// del +jsToolBar.prototype.elements.del = { + type: 'button', + title: 'Deleted', + fn: { + wiki: function() { this.singleTag('~~') } + } +} + +// code +jsToolBar.prototype.elements.code = { + type: 'button', + title: 'Code', + fn: { + wiki: function() { this.singleTag('`') } + } +} + +// spacer +jsToolBar.prototype.elements.space1 = {type: 'space'} + +// headings +jsToolBar.prototype.elements.h1 = { + type: 'button', + title: 'Heading 1', + fn: { + wiki: function() { + this.encloseLineSelection('# ', '',function(str) { + str = str.replace(/^#+\s+/, '') + return str; + }); + } + } +} +jsToolBar.prototype.elements.h2 = { + type: 'button', + title: 'Heading 2', + fn: { + wiki: function() { + this.encloseLineSelection('## ', '',function(str) { + str = str.replace(/^#+\s+/, '') + return str; + }); + } + } +} +jsToolBar.prototype.elements.h3 = { + type: 'button', + title: 'Heading 3', + fn: { + wiki: function() { + this.encloseLineSelection('### ', '',function(str) { + str = str.replace(/^#+\s+/, '') + return str; + }); + } + } +} + +// spacer +jsToolBar.prototype.elements.space2 = {type: 'space'} + +// ul +jsToolBar.prototype.elements.ul = { + type: 'button', + title: 'Unordered list', + fn: { + wiki: function() { + this.encloseLineSelection('','',function(str) { + str = str.replace(/\r/g,''); + return str.replace(/(\n|^)[#-]?\s*/g,"$1* "); + }); + } + } +} + +// ol +jsToolBar.prototype.elements.ol = { + type: 'button', + title: 'Ordered list', + fn: { + wiki: function() { + this.encloseLineSelection('','',function(str) { + str = str.replace(/\r/g,''); + return str.replace(/(\n|^)[*-]?\s*/g,"$11. "); + }); + } + } +} + +// spacer +jsToolBar.prototype.elements.space3 = {type: 'space'} + +// bq +jsToolBar.prototype.elements.bq = { + type: 'button', + title: 'Quote', + fn: { + wiki: function() { + this.encloseLineSelection('','',function(str) { + str = str.replace(/\r/g,''); + return str.replace(/(\n|^) *([^\n]*)/g,"$1> $2"); + }); + } + } +} + +// unbq +jsToolBar.prototype.elements.unbq = { + type: 'button', + title: 'Unquote', + fn: { + wiki: function() { + this.encloseLineSelection('','',function(str) { + str = str.replace(/\r/g,''); + return str.replace(/(\n|^) *[>]? *([^\n]*)/g,"$1$2"); + }); + } + } +} + +// pre +jsToolBar.prototype.elements.pre = { + type: 'button', + title: 'Preformatted text', + fn: { + wiki: function() { this.encloseLineSelection('~~~\n', '\n~~~') } + } +} + +// spacer +jsToolBar.prototype.elements.space4 = {type: 'space'} + +// wiki page +jsToolBar.prototype.elements.link = { + type: 'button', + title: 'Wiki link', + fn: { + wiki: function() { this.encloseSelection("[[", "]]") } + } +} +// image +jsToolBar.prototype.elements.img = { + type: 'button', + title: 'Image', + fn: { + wiki: function() { this.encloseSelection("![](", ")") } + } +} + +// spacer +jsToolBar.prototype.elements.space5 = {type: 'space'} diff --git a/test/unit/lib/redmine/wiki_formatting/markdown_formatter.rb b/test/unit/lib/redmine/wiki_formatting/markdown_formatter.rb new file mode 100644 index 000000000..b7409a0bf --- /dev/null +++ b/test/unit/lib/redmine/wiki_formatting/markdown_formatter.rb @@ -0,0 +1,62 @@ +# Redmine - project management software +# Copyright (C) 2006-2013 Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require File.expand_path('../../../../../test_helper', __FILE__) + +class Redmine::WikiFormatting::MarkdownFormatterTest < ActionView::TestCase + include ApplicationHelper + + def setup + @formatter = Redmine::WikiFormatting::Markdown::Formatter + end + + def test_inline_style + assert_equal "<p><strong>foo</strong></p>", @formatter.new("**foo**").to_html.strip + end + + def test_wiki_links_should_be_preserved + text = 'This is a wiki link: [[Foo]]' + assert_include '[[Foo]]', @formatter.new(text).to_html + end + + def test_redmine_links_with_double_quotes_should_be_preserved + text = 'This is a redmine link: version:"1.0"' + assert_include 'version:"1.0"', @formatter.new(text).to_html + end + + def test_should_support_syntax_highligth + text = <<-STR +~~~ruby +def foo +end +~~~ +STR + assert_select_in @formatter.new(text).to_html, 'pre code.ruby.syntaxhl' do + assert_select 'span.keyword', :text => 'def' + end + end + + def test_external_links_should_have_external_css_class + text = 'This is a [link](http://example.net/)' + assert_equal '<p>This is a <a class="external" href="http://example.net/">link</a></p>', @formatter.new(text).to_html.strip + end + + def test_locals_links_should_not_have_external_css_class + text = 'This is a [link](/issues)' + assert_equal '<p>This is a <a href="/issues">link</a></p>', @formatter.new(text).to_html.strip + end +end |