}
}
-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
Feeds the provided message into our centralised auditing system.
-=item C<markdown_parser>
+=item C<markdown>
-Returns a L<Bugzilla::Markdown::GFM::Parser> with the default extensions
-loaded (autolink, tagfilter, table, and strikethrough).
+Returns a L<Bugzilla::Markdown> object.
=back
# 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.
$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
}
);
}
@$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) = @_;
already_wrapped
type
extra_data
+ is_markdown
);
use constant UPDATE_COLUMNS => qw(
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 => {
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 {
C<boolean> Comment is marked as private.
+=item C<is_markdown>
+
+C<boolean> Whether this comment needs Markdown rendering to be applied.
+
=item C<already_wrapped>
If this comment is stored in the database word-wrapped, this will be C<1>.
},
{name => 'disable_bug_updates', type => 'b', default => 0},
+
+ {name => 'use_markdown', type => 'b', default => 0},
);
1;
--- /dev/null
+# 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;
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) = @_;
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};
}
{
isprivate => $attachment->isprivate,
type => CMT_ATTACHMENT_CREATED,
- extra_data => $attachment->id
+ extra_data => $attachment->id,
+ is_markdown => Bugzilla->params->{use_markdown} ? 1 : 0
}
);
{
isprivate => $attachment->isprivate,
type => CMT_ATTACHMENT_UPDATED,
- extra_data => $attachment->id
+ extra_data => $attachment->id,
+ is_markdown => Bugzilla->params->{use_markdown} ? 1 : 0
}
);
}
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**
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**
at [% comment.creation_ts FILTER time(undef, to_user.timezone) %]
</b>
[% END %]
- <pre class="comment" style="font-size: initial">[% comment.body_full({ wrap => 1 }) FILTER quoteUrls(bug, comment) %]</pre>
+ [% 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) %]</[% comment_tag FILTER none %]>
</div>
[% END %]
</div>
[% END %]
[% BLOCK comment_body %]
- <pre class="comment-text [%= "bz_private" IF comment.is_private %]" id="ct-[% comment.count FILTER none %]"
- [% IF comment.collapsed +%] style="display:none"[% END ~%]
+ [% IF comment.is_markdown AND Param('use_markdown') %]
+ [% comment_tag = 'div' %]
+ [% ELSE %]
+ [% comment_tag = 'pre' %]
+ [% END %]
+
+ <[% comment_tag FILTER none %] class="comment-text [%= "bz_private" IF comment.is_private %]"
+ id="ct-[% comment.count FILTER none %]"
+ data-comment-id="[% comment.id FILTER none %]"
+ [% IF comment.is_markdown +%] data-ismarkdown="true" [% END ~%]
+ [% IF comment.collapsed +%] style="display:none"[% END ~%]
>[% FILTER collapse %]
[% IF comment.is_about_attachment && comment.attachment.is_image ~%]
- <a href="[% basepath FILTER none %]attachment.cgi?id=[% comment.attachment.id FILTER none %]"
+ <a href="attachment.cgi?id=[% comment.attachment.id FILTER none %]"
title="[% comment.attachment.description FILTER html %]"
- class="lightbox"><img src="[% basepath FILTER none %]extensions/BugModal/web/image.png" width="16" height="16"></a>
+ class="lightbox"><img src="extensions/BugModal/web/image.png" width="16" height="16"></a>
[% END %]
[% END %]
- [%~ comment.body_full FILTER quoteUrls(bug, comment) ~%]</pre>
+ [%~ comment.body_full FILTER renderMarkdown(bug, comment) ~%]</[% comment_tag FILTER none %]>
[% END %]
[%
<textarea rows="5" cols="80" name="comment" id="comment" aria-labelledby="comment-edit-tab"></textarea>
</div>
<div id="comment-preview-tabpanel" class="comment-tabpanel" role="tabpanel" aria-labelledby="comment-preview-tab" style="display:none">
- <pre id="comment-preview" class="comment-text"></pre>
+ [% IF Param('use_markdown') %]
+ [% comment_tag = 'div' %]
+ [% ELSE %]
+ [% comment_tag = 'pre' %]
+ [% END %]
+ <[% comment_tag FILTER none %] id="comment-preview" class="comment-text"></[% comment_tag FILTER none %]>
</div>
- <div id="bugzilla-etiquette">
- <a href="[% basepath FILTER none %]page.cgi?id=etiquette.html" target="_blank" tabindex="-1">
- Comments Subject to Etiquette and Contributor Guidelines</a>
+ <div id="add-comment-tips">
+ [% IF Param('use_markdown') %]
+ <div id="comment-markdown-tip">
+ <img src="extensions/BMO/web/images/notice.png" width="16" height="16">
+ <a href="https://guides.github.com/features/mastering-markdown/" target="_blank">Markdown styling now supported</a>
+ </div>
+ [% END %]
+
+ <div id="bugzilla-etiquette">
+ <a href="page.cgi?id=etiquette.html" target="_blank" tabindex="-1">
+ Comments Subject to Etiquette and Contributor Guidelines</a>
+ </div>
</div>
<div id="after-comment-commit-button">
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;
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;
}
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;
}
clear: both;
width: 100%;
box-sizing: border-box !important;
- margin: 0 0 1em;
+ margin: 0 0 0.5em;
max-width: 1024px;
}
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) {
$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(
$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')) {
}
#comment {
- margin: 0px 0px 1em 0px;
+ margin: 0px 0px 0.5em 0px;
}
/*******************/
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;
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);
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;
}
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'), "<h1>header</h1>\n", 'Simple header');
'Autolink extension'
);
-is(
- $parser->render_html('<script>hijack()</script>'),
- "<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>'),
+ "<script>hijack()</script>\n",
+ 'Tagfilter extension'
+ );
+}
is(
$parser->render_html('~~strikethrough~~'),
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.",
} %]
<div id="comment_preview" class="bz_default_hidden bz_comment">
<div id="comment_preview_loading" class="bz_default_hidden">Generating Preview...</div>
<div id="comment_preview_error" class="bz_default_hidden"></div>
- <pre id="comment_preview_text" class="bz_comment_text"></pre>
+ <div id="comment_preview_text" class="bz_comment_text"></div>
+ </div>
+[% END %]
+
+[% IF Param('use_markdown') %]
+ <div id="comment-markdown-tip">
+ <img src="extensions/BMO/web/images/notice.png" width="16" height="16">
+ <a href="https://guides.github.com/features/mastering-markdown/" target="_blank">Markdown styling now supported</a>
</div>
[% END %]