]> git.ipfire.org Git - thirdparty/bugzilla.git/commitdiff
Bug 330707: Add optional support for MarkDown
authorKoosha KM <koosha.khajeh@gmail.com>
Thu, 28 Aug 2014 17:17:54 +0000 (17:17 +0000)
committerDavid Lawrence <dkl@mozilla.com>
Thu, 28 Aug 2014 17:17:54 +0000 (17:17 +0000)
r=dkl,a=sgreen

25 files changed:
Bugzilla.pm
Bugzilla/Bug.pm
Bugzilla/Comment.pm
Bugzilla/Constants.pm
Bugzilla/DB/Schema.pm
Bugzilla/Install.pm
Bugzilla/Install/DB.pm
Bugzilla/Install/Requirements.pm
Bugzilla/Markdown.pm [new file with mode: 0644]
Bugzilla/Template.pm
Bugzilla/WebService/Bug.pm
docs/en/rst/using.rst
js/field.js
post_bug.cgi
process_bug.cgi
skins/standard/global.css
t/004template.t
t/008filter.t
template/en/default/bug/comment.html.tmpl
template/en/default/bug/comments.html.tmpl
template/en/default/email/bugmail.html.tmpl
template/en/default/filterexceptions.pl
template/en/default/global/setting-descs.none.tmpl
template/en/default/pages/linked.html.tmpl
template/en/default/setup/strings.txt.pl

index 24ce65c2ef53bc371d594b97a36e10a3382d587a..e1f2fde3b514628195b3d8a0d8b8511101236bcc 100644 (file)
@@ -397,6 +397,11 @@ sub logout_request {
     # there. Don't rely on it: use Bugzilla->user->login instead!
 }
 
+sub markdown {
+    require Bugzilla::Markdown;
+    return $_[0]->request_cache->{markdown} ||= Bugzilla::Markdown->new();
+}
+
 sub job_queue {
     require Bugzilla::JobQueue;
     return $_[0]->request_cache->{job_queue} ||= Bugzilla::JobQueue->new();
@@ -944,6 +949,10 @@ Returns the local timezone of the Bugzilla installation,
 as a DateTime::TimeZone object. This detection is very time
 consuming, so we cache this information for future references.
 
+=item C<markdown>
+
+The current L<Markdown|Bugzilla::Markdown> object, to be used for Markdown rendering.
+
 =item C<job_queue>
 
 Returns a L<Bugzilla::JobQueue> that you can use for queueing jobs.
index 07266da9cfa6aa3615c483c30ad1209c2757f9e2..d03c63768b027c2fc1dee6d373b569b87c10c86d 100644 (file)
@@ -691,6 +691,8 @@ sub create {
       unless defined $params->{rep_platform};
     # Make sure a comment is always defined.
     $params->{comment} = '' unless defined $params->{comment};
+    $params->{is_markdown} = 0
+      unless defined $params->{is_markdown} && $params->{is_markdown} eq '1';
 
     $class->check_required_create_fields($params);
     $params = $class->run_create_validators($params);
@@ -704,6 +706,7 @@ sub create {
     my $blocked          = delete $params->{blocked};
     my $keywords         = delete $params->{keywords};
     my $creation_comment = delete $params->{comment};
+    my $is_markdown      = delete $params->{is_markdown};
     my $see_also         = delete $params->{see_also};
 
     # We don't want the bug to appear in the system until it's correctly
@@ -791,6 +794,7 @@ sub create {
 
     # We now have a bug id so we can fill this out
     $creation_comment->{'bug_id'} = $bug->id;
+    $creation_comment->{'is_markdown'} = $is_markdown;
 
     # Insert the comment. We always insert a comment on bug creation,
     # but sometimes it's blank.
@@ -2413,7 +2417,8 @@ sub set_all {
         # there are lots of things that want to check if we added a comment.
         $self->add_comment($params->{'comment'}->{'body'},
             { isprivate => $params->{'comment'}->{'is_private'},
-              work_time => $params->{'work_time'} });
+              work_time => $params->{'work_time'},
+              is_markdown => $params->{'comment'}->{'is_markdown'} });
     }
 
     if (exists $params->{alias} && $params->{alias}{set}) {
index c235b8d3085bd7e43bbf5a5498e426cd2518cb90..3dabe67028d7bfa84d12e0fd740d3f34b6ca3e63 100644 (file)
@@ -43,6 +43,7 @@ use constant DB_COLUMNS => qw(
     already_wrapped
     type
     extra_data
+    is_markdown
 );
 
 use constant UPDATE_COLUMNS => qw(
@@ -65,6 +66,7 @@ use constant VALIDATORS => {
     work_time   => \&_check_work_time,
     thetext     => \&_check_thetext,
     isprivate   => \&_check_isprivate,
+    is_markdown => \&Bugzilla::Object::check_boolean,
     extra_data  => \&_check_extra_data,
     type        => \&_check_type,
 };
@@ -177,6 +179,7 @@ sub body        { return $_[0]->{'thetext'};   }
 sub bug_id      { return $_[0]->{'bug_id'};    }
 sub creation_ts { return $_[0]->{'bug_when'};  }
 sub is_private  { return $_[0]->{'isprivate'}; }
+sub is_markdown { return $_[0]->{'is_markdown'}; }
 sub work_time   {
     # Work time is returned as a string (see bug 607909)
     return 0 if $_[0]->{'work_time'} + 0 == 0;
@@ -274,6 +277,7 @@ sub body_full {
 sub set_is_private  { $_[0]->set('isprivate',  $_[1]); }
 sub set_type        { $_[0]->set('type',       $_[1]); }
 sub set_extra_data  { $_[0]->set('extra_data', $_[1]); }
+sub set_is_markdown { $_[0]->set('is_markdown', $_[1]); }
 
 sub add_tag {
     my ($self, $tag) = @_;
@@ -522,6 +526,10 @@ C<string> Time spent as related to this comment.
 
 C<boolean> Comment is marked as private.
 
+=item C<is_markdown>
+
+C<boolean> Whether this comment needs L<Markdown|Bugzilla::Markdown> rendering to be applied.
+
 =item C<already_wrapped>
 
 If this comment is stored in the database word-wrapped, this will be C<1>.
@@ -617,6 +625,16 @@ A string, the full text of the comment as it would be displayed to an end-user.
 
 =cut
 
+=head2 Modifiers
+
+=over
+
+=item C<set_is_markdown>
+
+Sets whether this comment needs L<Markdown|Bugzilla::Markdown> rendering to be applied.
+
+=back
+
 =head1 B<Methods in need of POD>
 
 =over
index 59333795f6cd6cc9081b452bd9da0c03ddda7269..397a8e65f7b1304c47b045450a23d70426f91c8c 100644 (file)
@@ -191,6 +191,8 @@ use Memoize;
     AUDIT_REMOVE
 
     MOST_FREQUENT_THRESHOLD
+
+    MARKDOWN_TAB_WIDTH
 );
 
 @Bugzilla::Constants::EXPORT_OK = qw(contenttypes);
@@ -628,6 +630,10 @@ use constant AUDIT_REMOVE => '__remove__';
 # on the "Most frequently reported bugs" page.
 use constant MOST_FREQUENT_THRESHOLD => 2;
 
+# The number of spaces used to represent each tab character
+# by Markdown engine
+use constant MARKDOWN_TAB_WIDTH => 2;
+
 sub bz_locations {
     # Force memoize() to re-compute data per project, to avoid
     # sharing the same data across different installations.
index d1c1dc7e90a3dd24e94e1173792cf399f2ef04f1..ebe2cb4264ac8855d1ed770c732b3363b5162f21 100644 (file)
@@ -410,7 +410,8 @@ use constant ABSTRACT_SCHEMA => {
                                 DEFAULT => 'FALSE'},
             type            => {TYPE => 'INT2', NOTNULL => 1,
                                 DEFAULT => '0'},
-            extra_data      => {TYPE => 'varchar(255)'}
+            extra_data      => {TYPE => 'varchar(255)'},
+            is_markdown     => {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'FALSE'}
         ],
         INDEXES => [
             longdescs_bug_id_idx   => [qw(bug_id work_time)],
index 07bc9d6c3bc67c69027df9a6e0c4ed525274a292..5a2266e36fb4069ad2a670e67c094834272953ae 100644 (file)
@@ -90,6 +90,8 @@ sub SETTINGS {
     bugmail_new_prefix => { options => ['on', 'off'], default => 'on' },
     # 2013-07-26 joshi_sunil@in.com -- Bug 669535
     possible_duplicates => { options => ['on', 'off'], default => 'on' },
+    # 2014-05-24 koosha.khajeh@gmail.com -- Bug 1014164
+    use_markdown       => { options => ['on', 'off'], default => 'on' },
     }
 };
 
index 282aa7f108efada3a44d8435015cbe501efbcfe4..0b0603970eb8d0ac9e307d4c2730280077ddb1fd 100644 (file)
@@ -726,6 +726,10 @@ sub update_table_definitions {
     # 2014-08-11 sgreen@redhat.com - Bug 1012506
      _update_alias();
 
+    # 2014-08-14 koosha.khajeh@gmail.com - Bug 330707
+    $dbh->bz_add_column('longdescs', 'is_markdown',
+                        {TYPE => 'BOOLEAN', NOTNULL => 1, DEFAULT => 'FALSE'});
+
     ################################################################
     # New --TABLE-- changes should go *** A B O V E *** this point #
     ################################################################
index 06b061f5771e92be3bf7389e0cf74fef524cd801..2ceb01cfd64ce438840572373e267f8815cd3b4b 100644 (file)
@@ -405,6 +405,14 @@ sub OPTIONAL_MODULES {
         version => '0',
         feature => ['memcached'],
     },
+
+    # Markdown
+    {
+        package => 'Text-Markdown',
+        module  => 'Text::Markdown',
+        version => '1.0.26',
+        feature => ['markdown'],
+    }
     );
 
     my $extra_modules = _get_extension_requirements('OPTIONAL_MODULES');
@@ -428,6 +436,7 @@ use constant FEATURE_FILES => (
                       'Bugzilla/JobQueue/*', 'jobqueue.pl'],
     patch_viewer  => ['Bugzilla/Attachment/PatchReader.pm'],
     updates       => ['Bugzilla/Update.pm'],
+    markdown      => ['Bugzilla/Markdown.pm'],
     memcached     => ['Bugzilla/Memcache.pm'],
 );
 
diff --git a/Bugzilla/Markdown.pm b/Bugzilla/Markdown.pm
new file mode 100644 (file)
index 0000000..c5a34fb
--- /dev/null
@@ -0,0 +1,493 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+#
+# This Source Code Form is "Incompatible With Secondary Licenses", as
+# defined by the Mozilla Public License, v. 2.0.
+
+package Bugzilla::Markdown;
+
+use 5.10.1;
+use strict;
+use warnings;
+
+use Bugzilla::Constants;
+use Bugzilla::Template;
+
+use Digest::MD5 qw(md5_hex);
+
+use parent qw(Text::Markdown);
+
+@Bugzilla::Markdown::EXPORT = qw(new);
+
+# Regex to match balanced [brackets]. See Friedl's
+# "Mastering Regular Expressions", 2nd Ed., pp. 328-331.
+our ($g_nested_brackets, $g_nested_parens);
+$g_nested_brackets = qr{
+    (?>                                 # Atomic matching
+       [^\[\]]+                         # Anything other than brackets
+     |
+       \[
+         (??{ $g_nested_brackets })     # Recursive set of nested brackets
+       \]
+    )*
+}x;
+# Doesn't allow for whitespace, because we're using it to match URLs:
+$g_nested_parens = qr{
+    (?>                                 # Atomic matching
+       [^()\s]+                            # Anything other than parens or whitespace
+     |
+       \(
+         (??{ $g_nested_parens })        # Recursive set of nested brackets
+       \)
+    )*
+}x;
+
+our %g_escape_table;
+foreach my $char (split //, '\\`*_{}[]()>#+-.!~') {
+    $g_escape_table{$char} = md5_hex($char);
+}
+
+sub new {
+    my $invocant = shift;
+    my $class = ref $invocant || $invocant;
+    return $class->SUPER::new(tab_width => MARKDOWN_TAB_WIDTH,
+                              # Bugzilla uses HTML not XHTML
+                              empty_element_suffix => '>');
+}
+
+sub markdown {
+    my $self = shift;
+    my $text = shift;
+    my $user = Bugzilla->user;
+
+    if (Bugzilla->feature('markdown')
+        && $user->settings->{use_markdown}->{is_enabled}
+        && $user->setting('use_markdown') eq 'on')
+    {
+        return $self->SUPER::markdown($text, @_);
+    }
+
+    return Bugzilla::Template::quoteUrls($text);
+}
+
+sub _Markdown {
+    my $self = shift;
+    my $text = shift;
+
+    $text = Bugzilla::Template::quoteUrls($text);
+
+    return $self->SUPER::_Markdown($text, @_);
+}
+
+sub _RunSpanGamut {
+    # These are all the transformations that occur *within* block-level
+    # tags like paragraphs, headers, and list items.
+
+    my ($self, $text) = @_;
+
+    $text = $self->_DoCodeSpans($text);
+    $text = $self->_EscapeSpecialCharsWithinTagAttributes($text);
+    $text = $self->_EscapeSpecialChars($text);
+
+    $text = $self->_DoAnchors($text);
+
+    # Strikethroughs is Bugzilla's extension
+    $text = $self->_DoStrikethroughs($text);
+
+    $text = $self->_DoAutoLinks($text);
+    $text = $self->_EncodeAmpsAndAngles($text);
+    $text = $self->_DoItalicsAndBold($text);
+
+    $text =~ s/ {2,}\n/ <br$self->{empty_element_suffix}\n/g;
+
+    return $text;
+}
+
+# Override to check for HTML-escaped <>" chars.
+sub _StripLinkDefinitions {
+#
+# Strips link definitions from text, stores the URLs and titles in
+# hash references.
+#
+    my ($self, $text) = @_;
+    my $less_than_tab = $self->{tab_width} - 1;
+
+    # Link defs are in the form: ^[id]: url "optional title"
+    while ($text =~ s{
+            ^[ ]{0,$less_than_tab}\[(.+)\]: # id = \$1
+              [ \t]*
+              \n?               # maybe *one* newline
+              [ \t]*
+            (?:&lt;)?<a\s+href="(.+?)">\2</a>(?:&gt;)?          # url = \$2
+              [ \t]*
+              \n?               # maybe one newline
+              [ \t]*
+            (?:
+                (?<=\s)         # lookbehind for whitespace
+                (?:&quot;|\()
+                (.+?)           # title = \$3
+                (?:&quot;|\))
+                [ \t]*
+            )?  # title is optional
+            (?:\n+|\Z)
+        }{}omx) {
+        $self->{_urls}{lc $1} = $self->_EncodeAmpsAndAngles( $2 );    # Link IDs are case-insensitive
+        if ($3) {
+            $self->{_titles}{lc $1} = $3;
+            $self->{_titles}{lc $1} =~ s/"/&quot;/g;
+        }
+
+    }
+
+    return $text;
+}
+
+# We need to look for HTML-escaped '<' and '>' (i.e. &lt; and &gt;).
+# We also remove Email linkification from the original implementation
+# as it is already done in Bugzilla's quoteUrls().
+sub _DoAutoLinks {
+    my ($self, $text) = @_;
+
+    $text =~ s{(?:<|&lt;)((?:https?|ftp):[^'">\s]+)(?:>|&gt;)}{<a href="$1">$1</a>}gi;
+    return $text;
+}
+
+# The main reasons for overriding this method are
+# resolving URL conflicts with Bugzilla's quoteUrls()
+# and also changing '"' to '&quot;' in regular expressions wherever needed.
+sub _DoAnchors {
+#
+# Turn Markdown link shortcuts into <a> tags.
+#
+    my ($self, $text) = @_;
+
+    # We revert linkifications of non-email links and only
+    # those links whose URL and title are the same because
+    # this way we can be sure that link is generated by quoteUrls()
+    $text =~ s@<a \s+ href="(?! mailto ) (.+?)">\1</a>@$1@xmg;
+
+    #
+    # First, handle reference-style links: [link text] [id]
+    #
+    $text =~ s{
+        (                   # wrap whole match in $1
+          \[
+            ($g_nested_brackets)    # link text = $2
+          \]
+
+          [ ]?              # one optional space
+          (?:\n[ ]*)?       # one optional newline followed by spaces
+
+          \[
+            (.*?)       # id = $3
+          \]
+        )
+    }{
+        my $whole_match = $1;
+        my $link_text   = $2;
+        my $link_id     = lc $3;
+
+        if ($link_id eq "") {
+            $link_id = lc $link_text;   # for shortcut links like [this][].
+        }
+
+        $link_id =~ s{[ ]*\n}{ }g; # turn embedded newlines into spaces
+
+        $self->_GenerateAnchor($whole_match, $link_text, $link_id);
+    }xsge;
+
+    #
+    # Next, inline-style links: [link text](url "optional title")
+    #
+    $text =~ s{
+        (               # wrap whole match in $1
+          \[
+            ($g_nested_brackets)    # link text = $2
+          \]
+          \(            # literal paren
+            [ \t]*
+            ($g_nested_parens)   # href = $3
+            [ \t]*
+            (           # $4
+              (&quot;|')    # quote char = $5
+              (.*?)     # Title = $6
+              \5        # matching quote
+              [ \t]*    # ignore any spaces/tabs between closing quote and )
+            )?          # title is optional
+          \)
+        )
+    }{
+        my $result;
+        my $whole_match = $1;
+        my $link_text   = $2;
+        my $url         = $3;
+        my $title       = $6;
+
+        # Remove Bugzilla quoteUrls() linkification
+        if ($url =~ /^a href="/ && $url =~ m|</a$|) {
+            $url =~ s/^[^>]+>//;
+            $url =~ s@</a$@@;
+        }
+
+        # Limit URL to HTTP/HTTPS links
+        $url = "http://$url" unless $url =~ m!^https?://!i;
+
+        $self->_GenerateAnchor($whole_match, $link_text, undef, $url, $title);
+    }xsge;
+
+    #
+    # Last, handle reference-style shortcuts: [link text]
+    # These must come last in case you've also got [link test][1]
+    # or [link test](/foo)
+    #
+    $text =~ s{
+        (                    # wrap whole match in $1
+          \[
+            ([^\[\]]+)        # link text = $2; can't contain '[' or ']'
+          \]
+        )
+    }{
+        my $result;
+        my $whole_match = $1;
+        my $link_text   = $2;
+        (my $link_id = lc $2) =~ s{[ ]*\n}{ }g; # lower-case and turn embedded newlines into spaces
+
+        $self->_GenerateAnchor($whole_match, $link_text, $link_id);
+    }xsge;
+
+    return $text;
+}
+
+# The purpose of overriding this function is to add support
+# for a Github Flavored Markdown (GFM) feature called 'Multiple
+# underscores in words'. The standard markdown specification
+# specifies the underscore for making the text emphasized/bold.
+# However, some variable names in programming languages contain underscores
+# and we do not want a part of those variables to look emphasized/bold.
+# Instead, we render them as the way they originally are.
+sub _DoItalicsAndBold {
+    my ($self, $text) = @_;
+
+    # Handle at beginning of lines:
+    $text =~ s{ (^__ (?=\S) (.+?[*_]*) (?<=\S) __ (?!\S)) }
+              {
+                  my $result = _has_multiple_underscores($2) ? $1 : "<strong>$2</strong>";
+                  $result;
+              }gsxe;
+
+    $text =~ s{ ^\*\* (?=\S) (.+?[*_]*) (?<=\S) \*\* }{<strong>$1</strong>}gsx;
+
+    $text =~ s{ (^_ (?=\S) (.+?) (?<=\S) _ (?!\S)) }
+              {
+                  my $result = _has_multiple_underscores($2) ? $1 : "<em>$2</em>";
+                  $result;
+              }gsxe;
+
+    $text =~ s{ ^\* (?=\S) (.+?) (?<=\S) \* }{<em>$1</em>}gsx;
+
+    # <strong> must go first:
+    $text =~ s{ ( (?<=\W) __ (?=\S) (.+?[*_]*) (?<=\S) __ (?!\S) ) }
+              {
+                  my $result = _has_multiple_underscores($2) ? $1 : "<strong>$2</strong>";
+                  $result;
+              }gsxe;
+
+
+    $text =~ s{ (?<=\W) \*\* (?=\S) (.+?[*_]*) (?<=\S) \*\* }{<strong>$1</strong>}gsx;
+
+    $text =~ s{ ( (?<=\W) _ (?=\S) (.+?) (?<=\S) _ (?!\S) ) }
+              {
+                  my $result = _has_multiple_underscores($2) ? $1 : "<em>$2</em>";
+                  $result;
+              }gsxe;
+
+    $text =~ s{ (?<=\W) \* (?=\S) (.+?) (?<=\S) \* }{<em>$1</em>}gsx;
+
+    # And now, a second pass to catch nested strong and emphasis special cases
+    $text =~ s{ ( (?<=\W) __ (?=\S) (.+?[*_]*) (?<=\S) __ (\S*) ) }
+              {
+                  my $result = _has_multiple_underscores($3) ? $1 : "<strong>$2</strong>$3";
+                  $result;
+              }gsxe;
+
+    $text =~ s{ (?<=\W) \*\* (?=\S) (.+?[*_]*) (?<=\S) \*\* }{<strong>$1</strong>}gsx;
+    $text =~ s{ ( (?<=\W) _ (?=\S) (.+?) (?<=\S) _ (\S*) ) }
+              {
+                  my $result = _has_multiple_underscores($3) ? $1 : "<em>$2</em>$3";
+                  $result;
+              }gsxe;
+
+    $text =~ s{ (?<=\W) \* (?=\S) (.+?) (?<=\S) \* }{<em>$1</em>}gsx;
+
+    return $text;
+}
+
+# Override this function to ignore 'wrap_in_p_tags' from
+# the caller and to not generate <p> tags around the output.
+sub _FormParagraphs {
+    my ($self, $text) = @_;
+    return $self->SUPER::_FormParagraphs($text, { wrap_in_p_tags => 0 });
+}
+
+sub _DoStrikethroughs {
+    my ($self, $text) = @_;
+
+    $text =~ s{ ^ ~~ (?=\S) ([^~]+?) (?<=\S) ~~ (?!~) }{<del>$1</del>}gsx;
+    $text =~ s{ (?<=_|[^~\w]) ~~ (?=\S) ([^~]+?) (?<=\S) ~~ (?!~) }{<del>$1</del>}gsx;
+
+    return $text;
+}
+
+# The original _DoCodeSpans() uses the 's' modifier in its regex
+# which prevents _DoCodeBlocks() to match GFM fenced code blocks.
+# We copy the code from the original implementation and remove the
+# 's' modifier from it.
+sub _DoCodeSpans {
+    my ($self, $text) = @_;
+
+    $text =~ s@
+            (?<!\\)     # Character before opening ` can't be a backslash
+            (`+)        # $1 = Opening run of `
+            (.+?)       # $2 = The code block
+            (?<!`)
+            \1          # Matching closer
+            (?!`)
+        @
+             my $c = "$2";
+             $c =~ s/^[ \t]*//g; # leading whitespace
+             $c =~ s/[ \t]*$//g; # trailing whitespace
+             $c = $self->_EncodeCode($c);
+            "<code>$c</code>";
+        @egx;
+
+    return $text;
+}
+
+# Override to add GFM Fenced Code Blocks
+sub _DoCodeBlocks {
+    my ($self, $text) = @_;
+
+    $text =~ s{
+        ^ `{3,} [\s\t]* \n
+        (                # $1 = the entire code block
+          (?: .* \n+)+?
+        )
+        `{3,} [\s\t]* $
+        }{
+            my $codeblock = $1;
+            my $result;
+
+            $codeblock = $self->_EncodeCode($codeblock);
+            $codeblock = $self->_Detab($codeblock);
+            $codeblock =~ s/\n\z//; # remove the trailing newline
+
+            $result = "\n\n<pre><code>" . $codeblock . "</code></pre>\n\n";
+            $result;
+        }egmx;
+
+    # And now do the standard code blocks
+    $text = $self->SUPER::_DoCodeBlocks($text);
+
+    return $text;
+}
+
+sub _EncodeCode {
+    my ($self, $text) = @_;
+
+    # We need to unescape the escaped HTML characters in code blocks.
+    # These are the reverse of the escapings done in Bugzilla::Util::html_quote()
+    $text =~ s/&lt;/</g;
+    $text =~ s/&gt;/>/g;
+    $text =~ s/&quot;/"/g;
+    $text =~ s/&#64;/@/g;
+    # '&amp;' substitution must be the last one, otherwise a literal like '&gt;'
+    # will turn to '>' because '&' is already changed to '&amp;' in Bugzilla::Util::html_quote().
+    # In other words, html_quote() will change '&gt;' to '&amp;gt;' and then we will
+    # change '&amp;gt' -> '&gt;' -> '>' if we write this substitution as the first one.
+    $text =~ s/&amp;/&/g;
+    $text = $self->SUPER::_EncodeCode($text);
+    $text =~ s/~/$g_escape_table{'~'}/go;
+
+    return $text;
+}
+
+sub _EncodeBackslashEscapes {
+    my ($self, $text) = @_;
+
+    $text = $self->SUPER::_EncodeBackslashEscapes($text);
+    $text =~ s/\\~/$g_escape_table{'~'}/go;
+
+    return $text;
+}
+
+sub _UnescapeSpecialChars {
+    my ($self, $text) = @_;
+
+    $text = $self->SUPER::_UnescapeSpecialChars($text);
+    $text =~ s/$g_escape_table{'~'}/~/go;
+
+    return $text;
+}
+
+# Check if the passed string is of the form multiple_underscores_in_a_word.
+# To check that, we first need to make sure that the string does not contain
+# any white-space. Then, if the string is composed of non-space chunks which
+# are bound together with underscores, the string has the desired form.
+sub _has_multiple_underscores {
+    my $string = shift;
+    return 0 unless defined($string) && length($string);
+    return 0 if $string =~ /[\t\s]+/;
+    return 1 if scalar (split /_/, $string) > 1;
+    return 0;
+}
+
+1;
+
+__END__
+
+=head1 NAME
+
+Bugzilla::Markdown - Generates HTML output from structured plain-text input.
+
+=head1 SYNOPSIS
+
+ use Bugzilla::Markdown;
+
+ my $markdown = Bugzilla::Markdown->new();
+ print $markdown->markdown($text);
+
+=head1 DESCRIPTION
+
+Bugzilla::Markdown implements a Markdown engine that produces
+an HTML-based output from a given plain-text input.
+
+The majority of the implementation is done by C<Text::Markdown>
+CPAN module. It also applies the linkifications done in L<Bugzilla::Template>
+to the input resulting in an output which is a combination of both Markdown
+structures and those defined by Bugzilla itself.
+
+=head2 Accessors
+
+=over
+
+=item C<markdown>
+
+C<string> Produces an HTML-based output string based on the structures
+and format defined in the given plain-text input.
+
+=over
+
+=item B<Params>
+
+=over
+
+=item C<text>
+
+C<string> A plain-text string which includes Markdown structures.
+
+=back
+
+=back
+
+=back
index 7e3527857969f649677d21f8c5bf1b96d1643b75..78a3e41202a0e0cfd0a4404fa153d28ba0c3c267 100644 (file)
@@ -807,6 +807,23 @@ sub create {
                            1
                          ],
 
+            markdown => [ sub {
+                              my ($context, $bug, $comment, $user) = @_;
+                              return sub {
+                                  my $text = shift;
+                                  return unless $text;
+
+                                  if ((ref($comment) eq 'HASH' && $comment->{is_markdown})
+                                       || (ref($comment) eq 'Bugzilla::Comment' && $comment->is_markdown))
+                                  {
+                                      return Bugzilla->markdown->markdown($text);
+                                  }
+                                  return quoteUrls($text, $bug, $comment, $user);
+                              };
+                          },
+                          1
+                        ],
+
             bug_link => [ sub {
                               my ($context, $bug, $options) = @_;
                               return sub {
index 09a14ebf3ae1f399386d21c90fd63e38951f0706..0062ecc14f69c4fe2284a197c61b68422d77e381 100644 (file)
@@ -331,7 +331,9 @@ sub render_comment {
     Bugzilla->switch_to_shadow_db();
     my $bug = $params->{id} ? Bugzilla::Bug->check($params->{id}) : undef;
 
-    my $tmpl = '[% text FILTER quoteUrls(bug) %]';
+    my $markdown = $params->{markdown} ? 1 : 0;
+    my $tmpl = $markdown ? '[% text FILTER markdown(bug, { is_markdown => 1 }) %]' : '[% text FILTER markdown(bug) %]';
+
     my $html;
     my $template = Bugzilla->template;
     $template->process(
index ca7d40a4834e183683c6aa83f4e5bd15949f0c82..d470e263c5a0cdd30343d83835ea35081e9ce7ac 100644 (file)
@@ -785,6 +785,29 @@ Don't use sigs in comments. Signing your name ("Bill") is acceptable,
 if you do it out of habit, but full mail/news-style
 four line ASCII art creations are not.
 
+.. _markdown:
+
+Markdown
+--------
+
+Markdown lets you write your comments in a structured plain-text format and
+have your comments generated as HTML. For example, you may use Markdown for
+making a part of your comment look italic or bold in the generated HTML. Bugzilla
+supports most of the structures defined by `standard Markdown <http://daringfireball.net/projects/markdown/basics>`_.
+but does NOT support inline images and inline HTML.
+
+Additionally, three Github Flavored Markdown features are supported.
+
+- `Multiple underscores in words <https://help.github.com/articles/github-flavored-markdown#multiple-underscores-in-words>`_
+
+- `strikethrough <https://help.github.com/articles/github-flavored-markdown#strikethrough>`_
+
+- `fenced code blocks <https://help.github.com/articles/github-flavored-markdown#fenced-code-blocks>`_
+
+To use the Markdown feature, make sure that ``Enable Markdown support for comments`` is set to ``on``
+in your :ref:`userpreferences` and that you also check the ``Use Markdown for this comment`` option below
+the comment box when you want to submit a new comment.
+
 .. _comment-wrapping:
 
 Server-Side Comment Wrapping
index 7c5c5b64e3a1803d7b23b24259f45854492e07a6..20b08da48ff18a05844ba9d534d73b11cee5e4cc 100644 (file)
@@ -979,11 +979,13 @@ function initDirtyFieldTracking() {
  */
 
 var last_comment_text = '';
+var last_markdown_cb_value = null;
 
 function show_comment_preview(bug_id) {
     var Dom = YAHOO.util.Dom;
     var comment = document.getElementById('comment');
     var preview = document.getElementById('comment_preview');
+    var markdown_cb = document.getElementById('use_markdown');
 
     if (!comment || !preview) return;
     if (Dom.hasClass('comment_preview_tab', 'active_comment_tab')) return;
@@ -1003,7 +1005,7 @@ function show_comment_preview(bug_id) {
 
     Dom.addClass('comment_preview_error', 'bz_default_hidden');
 
-    if (last_comment_text == comment.value)
+    if (last_comment_text == comment.value && last_markdown_cb_value == markdown_cb.checked)
         return;
 
     Dom.addClass('comment_preview_text', 'bz_default_hidden');
@@ -1024,6 +1026,7 @@ function show_comment_preview(bug_id) {
                 Dom.addClass('comment_preview_loading', 'bz_default_hidden');
                 Dom.removeClass('comment_preview_text', 'bz_default_hidden');
                 last_comment_text = comment.value;
+                last_markdown_cb_value = markdown_cb.checked;
             }
         },
         failure: function(res) {
@@ -1039,7 +1042,8 @@ function show_comment_preview(bug_id) {
         params: {
             Bugzilla_api_token: BUGZILLA.api_token,
             id: bug_id,
-            text: comment.value
+            text: comment.value,
+            markdown: (markdown_cb != null) && markdown_cb.checked ? 1 : 0
         }
     })
     );
index 4365352e7fc85b538641fda4e65225118e4f922f..f73ca6b29e57aebf064480dee5101a9432dcc70b 100755 (executable)
@@ -118,6 +118,7 @@ foreach my $field (qw(cc groups)) {
     $bug_params{$field} = [$cgi->param($field)];
 }
 $bug_params{'comment'} = $comment;
+$bug_params{'is_markdown'} = $cgi->param('use_markdown');
 
 my @multi_selects = grep {$_->type == FIELD_TYPE_MULTI_SELECT && $_->enter_bug}
                          Bugzilla->active_custom_fields;
index 37beee14a5412d47b92328f520d1f9bab184e718..6ae5c54d66eba832c33b5d1ee1c8af5d7c87b60e 100755 (executable)
@@ -233,9 +233,13 @@ if (should_set('keywords')) {
     $set_all_fields{keywords}->{$action} = $cgi->param('keywords');
 }
 if (should_set('comment')) {
+    my $is_markdown = ($user->settings->{use_markdown}->{is_enabled} &&
+                            $cgi->param('use_markdown') eq '1') ? 1 : 0;
+
     $set_all_fields{comment} = {
-        body       => scalar $cgi->param('comment'),
-        is_private => scalar $cgi->param('comment_is_private'),
+        body        => scalar $cgi->param('comment'),
+        is_private  => scalar $cgi->param('comment_is_private'),
+        is_markdown => $is_markdown,
     };
 }
 if (should_set('see_also')) {
index 74005319a0ba78952bed451d9c2bc1306cc1e1d7..45aeb263478af10529c0e3a02781765ba1c5048a 100644 (file)
@@ -728,7 +728,7 @@ input.required, select.required, span.required_explanation {
 }
 
 #comment {
-    margin: 0px 0px 1em 0px;
+    margin: 0;
 }
 
 /*******************/
index d38f9e16b2bece113f597a5b80d1b788321214bb..b0ca5a629bb0a30aad1d06dd47fbcb652a32e142 100644 (file)
@@ -88,6 +88,7 @@ foreach my $include_path (@include_paths) {
             wrap_comment => sub { return $_ },
             none      => sub { return $_ } ,
             ics       => [ sub { return sub { return $_; } }, 1] ,
+            markdown => sub { return $_ } ,
         },
     }
     );
index f0a26d13f6668cf28fd0284caf04b45650d4f2aa..204bbdc43dcdf4bd77f69087ce6b9bd6bfe0c17e 100644 (file)
@@ -212,7 +212,7 @@ sub directive_ok {
     return 1 if $directive =~ /FILTER\ (html|csv|js|base64|css_class_quote|ics|
                                         quoteUrls|time|uri|xml|lower|html_light|
                                         obsolete|inactive|closed|unitconvert|
-                                        txt|html_linebreak|none)\b/x;
+                                        txt|html_linebreak|markdown|none)\b/x;
 
     return 0;
 }
index 96cbb63ed1fd44512754618977cd2684994e05c8..7feb6dfa4c5b72a848a929d4e6d7b651f46fb536 100644 (file)
     <pre id="comment_preview_text" class="bz_comment_text"></pre>
   </div>
 [% END %]
+
+[% IF feature_enabled('markdown') AND  user.settings.use_markdown.value == 'on' %]
+  <div id="comment_markdown">
+    <input type="checkbox" name="use_markdown" id="use_markdown" value="1"
+    [% "checked=\"checked\"" IF user.settings.use_markdown.value == 'on' %] >
+    <label id="use_markdown_label" for="use_markdown">Use Markdown for this [% terms.comment %]</label>
+  </div>
+[% END %]
index d040e651d1c12d4fd0d256e157c6747703f6f30d..8f5742feeb77eb1de6750e657a81f8d22911ef30 100644 (file)
   [% IF mode == "edit" || comment.collapsed %]
     id="comment_text_[% comment.count FILTER none %]"
   [% END %]>
-  [%- comment_text FILTER quoteUrls(bug, comment) -%]
+  [%- comment_text FILTER markdown(bug, comment) -%]
 </pre>
     [% Hook.process('a_comment-end', 'bug/comments.html.tmpl') %]
     </div>
index 9473a4459e8a24d54eefaaae5089c46d894aa599..b0f4efb45cf4e3eae543785aae477563f1055717 100644 (file)
@@ -25,7 +25,7 @@
               on [% "$terms.bug $bug.id" FILTER bug_link(bug, { full_url => 1, user => to_user }) FILTER none %]
               from [% INCLUDE global/user.html.tmpl user = to_user, who = comment.author %]</b>
           [% END %]
-        <pre>[% comment.body_full({ wrap => 1 }) FILTER quoteUrls(bug, comment, to_user) %]</pre>
+        <pre>[% comment.body_full({ wrap => 1 }) FILTER markdown(bug, comment, to_user) %]</pre>
         </div>
       [% END %]
       </p>
index 3ad9f685fcfab4e8edd4b6df15a217647b9a4b37..0b65414d71445d3a22e12a02f42e2a2418b98092 100644 (file)
@@ -20,7 +20,7 @@
 #                                   [% foo.push() %]
 # TT loop variables               - [% loop.count %]
 # Already-filtered stuff          - [% wibble FILTER html %]
-#   where the filter is one of html|csv|js|quoteUrls|time|uri|xml|none
+#   where the filter is one of html|csv|js|quoteUrls|time|uri|xml|markdown|none
 
 %::safe = (
 
index 6e3358782786754f32604b5292057f2da76d87e9..ac99094c59474410a8b27c38d33e2daa8479c7f4 100644 (file)
@@ -44,7 +44,8 @@
    "requestee_cc"                     => "Automatically add me to the CC list of $terms.bugs I am requested to review",
    "bugmail_new_prefix"               => "Add 'New:' to subject line of email sent when a new $terms.bug is filed",
    "possible_duplicates"              => "Display possible duplicates when reporting a new $terms.bug", 
-                   } 
+   "use_markdown"                     => "Enable Markdown support for $terms.comments"
+  }
 %]
 
 [% Hook.process('settings') %]
index 3fcf87952ca2bef6238b2e1274b02614f0014016..ab74470c25f3a55a314dcbb038fa6358588c431e 100644 (file)
@@ -18,7 +18,7 @@
 
 <p>
 <pre class="bz_comment_text">
-[%- cgi.param("text") FILTER quoteUrls FILTER html -%]
+[%- cgi.param("text") FILTER markdown FILTER html -%]
 </pre>
 </p>
 
@@ -33,7 +33,7 @@
 
 <p>
 <pre class="bz_comment_text">
-[%- cgi.param("text") FILTER quoteUrls -%]
+[%- cgi.param("text") FILTER markdown -%]
 </pre>
 </p>
 
index 7ac013feb7d567ae58a818961ebaa346c90fe257..dc6a52fe6c2408d4415e928ce423edd02e52c0f0 100644 (file)
@@ -102,6 +102,7 @@ END
     feature_xmlrpc            => 'XML-RPC Interface',
     feature_detect_charset    => 'Automatic charset detection for text attachments',
     feature_typesniffer       => 'Sniff MIME type of attachments',
+    feature_markdown          => 'Markdown syntax support for comments',
 
     file_remove => 'Removing ##name##...',
     file_rename => 'Renaming ##from## to ##to##...',