From: Israel Madueme Date: Tue, 11 Dec 2018 19:58:30 +0000 (-0500) Subject: Bug 1456878 - Support markdown comments X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6bbf0580c7c52e5db5f27202f4a33199a240e3ea;p=thirdparty%2Fbugzilla.git Bug 1456878 - Support markdown comments Adds support for markdown comments, notable features: - Markdown rendering with the GFM library - All new comments are treated as markdown - new lines treated as new lines (hardbreaks extension on); this makes it so that you don't need 2 spaces a the end of a line to make a new line and is what github does too. - BMO link previews for bug shorthand ~~as well as custom markdown links~~ (link previews on markdown links left for another day). - quoteUrls is used on the actual text nodes after markdown html is rendered. - Works with comment editing - Quoting a markdown comment will fetch the raw markdown - Does *not* attempt to update the non-Bug Modal ui with markdown rendering, as requested in https://bugzilla.mozilla.org/show_bug.cgi?id=1456878#c9 - Adds a 'use_markdown' feature flag to the Advanced parameters. Things work as expected when on vs off. --- diff --git a/Bugzilla.pm b/Bugzilla.pm index bf82df668..c21b450b2 100644 --- a/Bugzilla.pm +++ b/Bugzilla.pm @@ -795,12 +795,10 @@ sub check_rate_limit { } } -sub markdown_parser { - require Bugzilla::Markdown::GFM; - require Bugzilla::Markdown::GFM::Parser; - return request_cache->{markdown_parser} - ||= Bugzilla::Markdown::GFM::Parser->new( - {extensions => [qw( autolink tagfilter table strikethrough)]}); +sub markdown { + require Bugzilla::Markdown; + state $markdown = Bugzilla::Markdown->new; + return $markdown; } # Private methods @@ -1121,10 +1119,9 @@ of features, see C in C. Feeds the provided message into our centralised auditing system. -=item C +=item C -Returns a L with the default extensions -loaded (autolink, tagfilter, table, and strikethrough). +Returns a L object. =back diff --git a/Bugzilla/Bug.pm b/Bugzilla/Bug.pm index 5673ab6e3..102ea78e7 100644 --- a/Bugzilla/Bug.pm +++ b/Bugzilla/Bug.pm @@ -995,6 +995,9 @@ sub create { # We now have a bug id so we can fill this out $creation_comment->{'bug_id'} = $bug->id; + if (Bugzilla->params->{use_markdown}) { + $creation_comment->{'is_markdown'} = 1; + } # Insert the comment. We always insert a comment on bug creation, # but sometimes it's blank. @@ -2726,8 +2729,9 @@ sub set_all { $self->add_comment( $params->{'comment'}->{'body'}, { - isprivate => $params->{'comment'}->{'is_private'}, - work_time => $params->{'work_time'} + isprivate => $params->{'comment'}->{'is_private'}, + work_time => $params->{'work_time'}, + is_markdown => Bugzilla->params->{use_markdown} ? 1 : 0 } ); } @@ -3248,7 +3252,7 @@ sub remove_cc { @$cc_users = grep { $_->id != $user->id } @$cc_users; } -# $bug->add_comment("comment", {isprivate => 1, work_time => 10.5, +# $bug->add_comment("comment", {isprivate => 1, work_time => 10.5, is_markdown => 1, # type => CMT_NORMAL, extra_data => $data}); sub add_comment { my ($self, $comment, $params) = @_; diff --git a/Bugzilla/Comment.pm b/Bugzilla/Comment.pm index 50bab3fec..5604175ae 100644 --- a/Bugzilla/Comment.pm +++ b/Bugzilla/Comment.pm @@ -45,6 +45,7 @@ use constant DB_COLUMNS => qw( already_wrapped type extra_data + is_markdown ); use constant UPDATE_COLUMNS => qw( @@ -62,14 +63,15 @@ use constant ID_FIELD => 'comment_id'; use constant LIST_ORDER => 'bug_when, comment_id'; use constant VALIDATORS => { - bug_id => \&_check_bug_id, - who => \&_check_who, - bug_when => \&_check_bug_when, - work_time => \&_check_work_time, - thetext => \&_check_thetext, - isprivate => \&_check_isprivate, - extra_data => \&_check_extra_data, - type => \&_check_type, + bug_id => \&_check_bug_id, + who => \&_check_who, + bug_when => \&_check_bug_when, + 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, }; use constant VALIDATOR_DEPENDENCIES => { @@ -234,6 +236,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 { @@ -579,6 +582,10 @@ C Time spent as related to this comment. C Comment is marked as private. +=item C + +C Whether this comment needs Markdown rendering to be applied. + =item C If this comment is stored in the database word-wrapped, this will be C<1>. diff --git a/Bugzilla/Config/Advanced.pm b/Bugzilla/Config/Advanced.pm index 7b76944e1..26d5d1246 100644 --- a/Bugzilla/Config/Advanced.pm +++ b/Bugzilla/Config/Advanced.pm @@ -28,6 +28,8 @@ use constant get_param_list => ( }, {name => 'disable_bug_updates', type => 'b', default => 0}, + + {name => 'use_markdown', type => 'b', default => 0}, ); 1; diff --git a/Bugzilla/Markdown.pm b/Bugzilla/Markdown.pm new file mode 100644 index 000000000..6a69694d4 --- /dev/null +++ b/Bugzilla/Markdown.pm @@ -0,0 +1,74 @@ +# 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 Moo; + +use Encode; +use Mojo::DOM; +use HTML::Escape qw(escape_html); +use List::MoreUtils qw(any); + +has 'markdown_parser' => (is => 'lazy'); +has 'bugzilla_shorthand' => ( + is => 'ro', + default => sub { + require Bugzilla::Template; + \&Bugzilla::Template::quoteUrls; + } +); + +sub _build_markdown_parser { + if (Bugzilla->has_feature('alien_cmark')) { + require Bugzilla::Markdown::GFM; + require Bugzilla::Markdown::GFM::Parser; + return Bugzilla::Markdown::GFM::Parser->new({ + hardbreaks => 1, + validate_utf8 => 1, + safe => 1, + extensions => [qw( autolink tagfilter table strikethrough )], + }); + } + else { + return undef; + } +} + +sub render_html { + my ($self, $markdown, $bug, $comment, $user) = @_; + my $parser = $self->markdown_parser; + return escape_html($markdown) unless $parser; + + my @valid_text_parent_tags = ('p', 'li', 'td'); + my @bad_tags = qw( img ); + my $bugzilla_shorthand = $self->bugzilla_shorthand; + my $html = decode('UTF-8', $parser->render_html($markdown)); + my $dom = Mojo::DOM->new($html); + + $dom->find(join(', ', @bad_tags))->map('remove'); + $dom->find(join ', ', @valid_text_parent_tags)->map(sub { + my $node = shift; + $node->descendant_nodes->map(sub { + my $child = shift; + if ( $child->type eq 'text' + && $child->children->size == 0 + && any { $child->parent->tag eq $_ } @valid_text_parent_tags) + { + my $text = $child->content; + $child->replace(Mojo::DOM->new($bugzilla_shorthand->($text))); + } + return $child; + }); + return $node; + }); + return $dom->to_string; + +} + + +1; diff --git a/Bugzilla/Template.pm b/Bugzilla/Template.pm index fbc869064..23ef28a4d 100644 --- a/Bugzilla/Template.pm +++ b/Bugzilla/Template.pm @@ -739,6 +739,22 @@ sub create { 1 ], + renderMarkdown => [ + sub { + my ($context, $bug, $comment, $user) = @_; + return sub { + my $text = shift; + if ($comment && $comment->is_markdown && Bugzilla->params->{use_markdown}) { + return Bugzilla->markdown->render_html($text, $bug, $comment, $user); + } + else { + return quoteUrls($text, $bug, $comment, $user); + } + }; + }, + 1 + ], + bug_link => [ sub { my ($context, $bug, $options) = @_; diff --git a/Bugzilla/WebService/Bug.pm b/Bugzilla/WebService/Bug.pm index c04d1bc28..e4727ed56 100644 --- a/Bugzilla/WebService/Bug.pm +++ b/Bugzilla/WebService/Bug.pm @@ -370,7 +370,10 @@ sub render_comment { Bugzilla->switch_to_shadow_db(); my $bug = $params->{id} ? Bugzilla::Bug->check($params->{id}) : undef; - my $html = Bugzilla::Template::quoteUrls($params->{text}, $bug); + my $html + = Bugzilla->params->{use_markdown} + ? Bugzilla->markdown->render_html($params->{text}, $bug) + : Bugzilla::Template::quoteUrls($params->{text}, $bug); return {html => $html}; } diff --git a/attachment.cgi b/attachment.cgi index 01ee9a9e2..51c0f012d 100755 --- a/attachment.cgi +++ b/attachment.cgi @@ -621,7 +621,8 @@ sub insert { { isprivate => $attachment->isprivate, type => CMT_ATTACHMENT_CREATED, - extra_data => $attachment->id + extra_data => $attachment->id, + is_markdown => Bugzilla->params->{use_markdown} ? 1 : 0 } ); @@ -780,7 +781,8 @@ sub update { { isprivate => $attachment->isprivate, type => CMT_ATTACHMENT_UPDATED, - extra_data => $attachment->id + extra_data => $attachment->id, + is_markdown => Bugzilla->params->{use_markdown} ? 1 : 0 } ); } diff --git a/docs/en/rst/api/core/v1/comment.rst b/docs/en/rst/api/core/v1/comment.rst index 2e6ca1e29..a1ef7abb3 100644 --- a/docs/en/rst/api/core/v1/comment.rst +++ b/docs/en/rst/api/core/v1/comment.rst @@ -102,6 +102,8 @@ creation_time datetime This is exactly same as the ``time`` key. Use this is_private boolean ``true`` if this comment is private (only visible to a certain group called the "insidergroup"), ``false`` otherwise. +is_markdown boolean ``true`` if this comment is markdown. ``false`` if this + comment is plaintext. ============= ======== ======================================================== **Errors** @@ -123,7 +125,8 @@ it can also throw the following errors: Create Comments --------------- -This allows you to add a comment to a bug in Bugzilla. +This allows you to add a comment to a bug in Bugzilla. All comments created via the +API will be considered Markdown (specifically GitHub Flavored Markdown). **Request** diff --git a/extensions/BMO/template/en/default/email/bugmail.html.tmpl b/extensions/BMO/template/en/default/email/bugmail.html.tmpl index 7f2754fdc..0e6620bf8 100644 --- a/extensions/BMO/template/en/default/email/bugmail.html.tmpl +++ b/extensions/BMO/template/en/default/email/bugmail.html.tmpl @@ -50,7 +50,12 @@ at [% comment.creation_ts FILTER time(undef, to_user.timezone) %] [% END %] -
[% comment.body_full({ wrap => 1 }) FILTER quoteUrls(bug, comment) %]
+ [% IF comment.is_markdown AND Param('use_markdown') %] + [% comment_tag = 'div' %] + [% ELSE %] + [% comment_tag = 'pre' %] + [% END %] + <[% comment_tag FILTER none %] class="comment" style="font-size: initial">[% comment.body_full({ wrap => 1 }) FILTER renderMarkdown(bug, comment) %] [% END %] diff --git a/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl index 03aa91639..4aac9524d 100644 --- a/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/activity_stream.html.tmpl @@ -261,16 +261,25 @@ [% END %] [% BLOCK comment_body %] - + [%~ comment.body_full FILTER renderMarkdown(bug, comment) ~%] [% END %] [% diff --git a/extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl b/extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl index bb633788f..83502ed96 100644 --- a/extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl +++ b/extensions/BugModal/template/en/default/bug_modal/new_comment.html.tmpl @@ -45,12 +45,26 @@ -
- - Comments Subject to Etiquette and Contributor Guidelines +
+ [% IF Param('use_markdown') %] + + [% END %] + +
diff --git a/extensions/BugModal/web/bug_modal.css b/extensions/BugModal/web/bug_modal.css index ad3fd481d..cb85e3faf 100644 --- a/extensions/BugModal/web/bug_modal.css +++ b/extensions/BugModal/web/bug_modal.css @@ -680,8 +680,8 @@ h3.change-name { body.platform-Win32 .comment-text, body.platform-Win64 .comment-text { font-family: "Fira Mono", monospace; } - -.comment-text span.quote, .comment-text span.quote_wrapped { +.comment-text span.quote, .comment-text span.quote_wrapped, +div.comment-text pre { background: #eee !important; color: #444 !important; display: block !important; @@ -695,6 +695,40 @@ body.platform-Win32 .comment-text, body.platform-Win64 .comment-text { border: 1px dashed darkred; } +/* Markdown comments */ +div.comment-text { + white-space: normal; + padding: 0 8px 0 8px; + font-family: inherit !important; +} + +div.comment-text code { + color: #444; + background-color: #eee; + font-size: 13px; + font-family: "Fira Mono","Droid Sans Mono",Menlo,Monaco,"Courier New",monospace; +} + +div.comment-text table { + border-collapse: collapse; +} + +div.comment-text th, div.comment-text td { + padding: 5px 10px; + border: 1px solid #ccc; +} + +div.comment-text hr { + display: block !important; +} + +div.comment-text blockquote { + background: #fcfcfc; + border-left: 5px solid #ccc; + margin: 1.5em 10px; + padding: 0.5em 10px; +} + .comment-tags { padding: 0 8px !important; } @@ -768,11 +802,16 @@ body.platform-Win32 .comment-text, body.platform-Win64 .comment-text { margin-top: 20px; } -#add-comment-private, -#bugzilla-etiquette { +#add-comment-private { float: right; } +#add-comment-tips { + display: flex; + justify-content: space-between; + margin-bottom: 1em; +} + #comment { border: 1px solid #ccc; } @@ -781,7 +820,7 @@ body.platform-Win32 .comment-text, body.platform-Win64 .comment-text { clear: both; width: 100%; box-sizing: border-box !important; - margin: 0 0 1em; + margin: 0 0 0.5em; max-width: 1024px; } diff --git a/extensions/BugModal/web/bug_modal.js b/extensions/BugModal/web/bug_modal.js index 59d7b2ce5..3e973fe1e 100644 --- a/extensions/BugModal/web/bug_modal.js +++ b/extensions/BugModal/web/bug_modal.js @@ -858,31 +858,54 @@ $(function() { var prefix = "(In reply to " + comment_author + " from comment #" + comment_id + ")\n"; var reply_text = ""; - if (BUGZILLA.user.settings.quote_replies == 'quoted_reply') { - var text = $('#ct-' + comment_id).text(); - reply_text = prefix + wrapReplyText(text); - } else if (BUGZILLA.user.settings.quote_replies == 'simply_reply') { - reply_text = prefix; + + var quoteMarkdown = function($comment) { + const uid = $comment.data('comment-id'); + bugzilla_ajax( + { + url: `rest/bug/comment/${uid}`, + }, + (data) => { + const quoted = data['comments'][uid]['text'].replace(/\n/g, "\n> "); + reply_text = `${prefix}\n> ${quoted}`; + populateNewComment(); + } + ); } - // quoting a private comment, check the 'private' cb - $('#add-comment-private-cb').prop('checked', - $('#add-comment-private-cb:checked').length || $('#is-private-' + comment_id + ':checked').length); + var populateNewComment = function() { + // quoting a private comment, check the 'private' cb + $('#add-comment-private-cb').prop('checked', + $('#add-comment-private-cb:checked').length || $('#is-private-' + comment_id + ':checked').length); - // remove embedded links to attachment details - reply_text = reply_text.replace(/(attachment\s+\d+)(\s+\[[^\[\n]+\])+/gi, '$1'); + // remove embedded links to attachment details + reply_text = reply_text.replace(/(attachment\s+\d+)(\s+\[[^\[\n]+\])+/gi, '$1'); - $.scrollTo($('#comment'), function() { - if ($('#comment').val() != reply_text) { - $('#comment').val($('#comment').val() + reply_text); - } + $.scrollTo($('#comment'), function() { + if ($('#comment').val() != reply_text) { + $('#comment').val($('#comment').val() + reply_text); + } - if (BUGZILLA.user.settings.autosize_comments) { - autosize.update($('#comment')); - } + if (BUGZILLA.user.settings.autosize_comments) { + autosize.update($('#comment')); + } - $('#comment').focus(); - }); + $('#comment').trigger('input').focus(); + }); + } + + if (BUGZILLA.user.settings.quote_replies == 'quoted_reply') { + var $comment = $('#ct-' + comment_id); + if ($comment.attr('data-ismarkdown')) { + quoteMarkdown($comment); + } else { + reply_text = prefix + wrapReplyText($comment.text()); + populateNewComment(); + } + } else if (BUGZILLA.user.settings.quote_replies == 'simply_reply') { + reply_text = prefix; + populateNewComment(); + } }); if (BUGZILLA.user.settings.autosize_comments) { diff --git a/extensions/EditComments/lib/WebService.pm b/extensions/EditComments/lib/WebService.pm index ebb93d678..338cc3907 100644 --- a/extensions/EditComments/lib/WebService.pm +++ b/extensions/EditComments/lib/WebService.pm @@ -141,11 +141,15 @@ sub update_comment { $comment->{thetext} = $new_comment; $bug->_sync_fulltext(update_comments => 1); + my $html + = $comment->is_markdown && Bugzilla->params->{use_markdown} + ? Bugzilla->markdown->render_html($new_comment, $bug) + : Bugzilla::Template::quoteUrls($new_comment, $bug); + # Respond with the updated comment and number of revisions return { - text => $self->type('string', $new_comment), - html => - $self->type('string', Bugzilla::Template::quoteUrls($new_comment, $bug)), + text => $self->type('string', $new_comment), + html => $self->type('string', $html), count => $self->type( 'int', $dbh->selectrow_array( diff --git a/process_bug.cgi b/process_bug.cgi index c72d72d63..bae5b1719 100755 --- a/process_bug.cgi +++ b/process_bug.cgi @@ -275,6 +275,7 @@ if (should_set('comment')) { $set_all_fields{comment} = { body => scalar $cgi->param('comment'), is_private => scalar $cgi->param('comment_is_private'), + is_markdown => Bugzilla->params->{use_markdown} ? 1 : 0, }; } if (should_set('see_also')) { diff --git a/skins/standard/global.css b/skins/standard/global.css index 76f6aa250..2ff552963 100644 --- a/skins/standard/global.css +++ b/skins/standard/global.css @@ -963,7 +963,7 @@ input.required, select.required, span.required_explanation { } #comment { - margin: 0px 0px 1em 0px; + margin: 0px 0px 0.5em 0px; } /*******************/ @@ -1518,7 +1518,8 @@ table.edit_form hr { left: 16px; } -.bz_comment_text span.quote, .bz_comment_text span.quote_wrapped { +.bz_comment_text span.quote, .bz_comment_text span.quote_wrapped, +div.bz_comment_text pre { background: #eee !important; color: #444 !important; display: block !important; @@ -1528,6 +1529,40 @@ table.edit_form hr { padding: 5px !important; } +/* Markdown comments */ +div.bz_comment_text { + white-space: normal; + padding: 0 8px 0 8px; + font-family: inherit !important; +} + +div.bz_comment_text code { + color: #444; + background-color: #eee; + font-size: 13px; + font-family: "Fira Mono","Droid Sans Mono",Menlo,Monaco,"Courier New",monospace; +} + +div.bz_comment_text table { + border-collapse: collapse; +} + +div.bz_comment_text th, div.bz_comment_text td { + padding: 5px 10px; + border: 1px solid #ccc; +} + +div.bz_comment_text hr { + display: block !important; +} + +div.bz_comment_text blockquote { + background: #fcfcfc; + border-left: 5px solid #ccc; + margin: 1.5em 10px; + padding: 0.5em 10px; +} + .bz_comment_tags { background: #eee; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); diff --git a/t/008filter.t b/t/008filter.t index 6c9924020..671b8ff81 100644 --- a/t/008filter.t +++ b/t/008filter.t @@ -221,7 +221,7 @@ sub directive_ok { quoteUrls|time|uri|xml|lower|html_light| obsolete|inactive|closed|unitconvert| txt|html_linebreak|none|json|null|id| - markdown)\b/x; + markdown|renderMarkdown)\b/x; return 0; } diff --git a/t/markdown.t b/t/markdown.t index ca31fcdc0..23f6692d1 100644 --- a/t/markdown.t +++ b/t/markdown.t @@ -8,10 +8,20 @@ use 5.10.1; use strict; use warnings; use lib qw( . lib local/lib/perl5 ); + +use Bugzilla::Test::MockDB; +use Bugzilla::Test::MockParams (password_complexity => 'no_constraints'); use Bugzilla; use Test2::V0; -my $parser = Bugzilla->markdown_parser; +my $have_cmark_gfm = eval { + require Alien::libcmark_gfm; + require Bugzilla::Markdown::GFM; +}; + +plan skip_all => "these tests require Alien::libcmark_gfm" unless $have_cmark_gfm; + +my $parser = Bugzilla->markdown; is($parser->render_html('# header'), "

header

\n", 'Simple header'); @@ -27,11 +37,14 @@ is( 'Autolink extension' ); -is( - $parser->render_html(''), - "<script>hijack()</script>\n", - 'Tagfilter extension' -); +SKIP: { + skip("currently no raw html is allowed via the safe option", 1); + is( + $parser->render_html(''), + "<script>hijack()</script>\n", + 'Tagfilter extension' + ); +} is( $parser->render_html('~~strikethrough~~'), diff --git a/template/en/default/admin/params/advanced.html.tmpl b/template/en/default/admin/params/advanced.html.tmpl index 2fe59c533..ff63f62b5 100644 --- a/template/en/default/admin/params/advanced.html.tmpl +++ b/template/en/default/admin/params/advanced.html.tmpl @@ -67,4 +67,9 @@ disable_bug_updates => "When enabled, all updates to $terms.bugs will be blocked.", + + use_markdown => + "When enabled, existing markdown comments will be rendered as markdown" + _ " and new comments will be treated as markdown. When disabled ALL comments," + _ " will be rendered as plaintext and new comments will be plaintext.", } %] diff --git a/template/en/default/bug/comment.html.tmpl b/template/en/default/bug/comment.html.tmpl index bbb1d9e84..77c393366 100644 --- a/template/en/default/bug/comment.html.tmpl +++ b/template/en/default/bug/comment.html.tmpl @@ -37,6 +37,13 @@
Generating Preview...
-

+    
+
+[% END %] + +[% IF Param('use_markdown') %] + [% END %]