Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions Bugzilla/Markdown.pm
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,30 @@ sub _build_markdown_parser {
}

my $MARKDOWN_OFF = quotemeta '#[markdown(off)]';

# The only raw HTML allowed in comments: GitHub-style collapsible sections.
# Markdown rendering escapes all tags, so the escaped text is swapped back to
# real elements afterwards. Only these exact tags are recognized and they never
# carry attributes, so no other markup can be smuggled in.
my %DISCLOSURE_MARKER = (
'<details>' => "\x{E000}",
'</details>' => "\x{E001}",
'<summary>' => "\x{E002}",
'</summary>' => "\x{E003}",
);

# Markdown wraps the tags in a paragraph. Closing and reopening it lets the
# HTML parser lift the block level disclosure elements out of the paragraph;
# the empty paragraphs left behind are dropped afterwards.
my %DISCLOSURE_HTML = (
"\x{E000}" => '</p><details><p>',
"\x{E001}" => '</p></details><p>',
"\x{E002}" => '</p><summary>',
"\x{E003}" => '</summary><p>',
);

my $DISCLOSURE_RE = qr{</?(?:details|summary)>}i;

sub render_html {
my ($self, $markdown, $bug, $comment, $user) = @_;
my $parser = $self->markdown_parser;
Expand All @@ -60,9 +84,12 @@ sub render_html {
return $html;
}

my $has_disclosure = $markdown =~ $DISCLOSURE_RE;

# Replace < with \x{FFFD} (special unicode replacement character),
# and remove \x{FFFD} later.
$markdown =~ tr/\x{FFFD}//d;
# and remove \x{FFFD} later. The private use characters reserved for the
# disclosure markers are dropped too, so they can't be forged in a comment.
$markdown =~ tr/\x{FFFD}\x{E000}-\x{E003}//d;
$markdown =~ s{<(?!https?://)}{\x{FFFD}}gs;

my @valid_text_parent_tags = ('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'td');
Expand Down Expand Up @@ -91,8 +118,40 @@ sub render_html {
});
return $node;
});
return $dom->to_string;
return $has_disclosure ? _expand_disclosure_tags($dom) : $dom->to_string;
}

# Turn the escaped <details>/<summary> text left by the markdown renderer back
# into real elements. Text inside code blocks is skipped so the syntax can
# still be documented in a comment.
sub _expand_disclosure_tags {
my ($dom) = @_;

my $found = 0;
$dom->descendant_nodes->each(sub {
my ($node) = @_;
return unless $node->type eq 'text';
return if $node->ancestors('pre, code')->size;
my $text = $node->content;
return unless $text =~ s/($DISCLOSURE_RE)/$DISCLOSURE_MARKER{lc $1}/g;
$found = 1;
$node->content($text);
});

my $html = $dom->to_string;
return $html unless $found;

$html =~ s/([\x{E000}-\x{E003}])/$DISCLOSURE_HTML{$1}/g;

# Drop the line breaks and empty paragraphs the rewrite leaves behind.
$html =~ s{\s*<br\s*/?>\s*(?=</p>)}{}g;
$html =~ s{(?<=<p>)\s*<br\s*/?>\s*}{}g;

my $expanded = Mojo::DOM->new($html);
$expanded->find('p')
->grep(sub { !$_->children->size && $_->all_text !~ /\S/ })->map('remove');

return $expanded->to_string;
}

sub _is_external_link {
Expand Down
12 changes: 12 additions & 0 deletions skins/standard/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -2724,6 +2724,18 @@ div.bz_comment_text pre {
margin: 0;
}

.markdown-body details {
margin-bottom: 10px;
}

.markdown-body details > *:last-child {
margin-bottom: 0;
}

.markdown-body summary {
cursor: pointer;
}

.markdown-body ul,
.markdown-body ol {
padding-left: 0;
Expand Down
63 changes: 63 additions & 0 deletions t/markdown.t
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,67 @@ is($ahref->attr('href'), 'https://searchfox.org/mozilla-central/rev/76fe4bb38534

is($parser->render_html('<foo>'), "<p>&lt;foo&gt;</p>\n", "literal tags work");

# Bug 2060932: collapsible sections via <details>/<summary>.
is(
$parser->render_html('<details><summary>Text to click</summary>'
. 'Text hidden by default</details>'),
'<details><summary>Text to click</summary>'
. "<p>Text hidden by default</p></details>\n",
'Disclosure tags on a single line'
);

my $details_block = <<'MARKDOWN';
<details>
<summary>Click **me**</summary>

Hidden content

</details>
MARKDOWN

is(
$parser->render_html($details_block),
"<details><summary>Click <strong>me</strong></summary>\n"
. "<p>Hidden content</p>\n</details>\n",
'Disclosure tags as their own blocks, with markdown in the summary'
);

is(
$parser->render_html("<DETAILS><SUMMARY>Up</SUMMARY>hidden</DETAILS>"),
"<details><summary>Up</summary><p>hidden</p></details>\n",
'Disclosure tags are case insensitive'
);

is(
$parser->render_html("```\n<details><summary>x</summary>y</details>\n```"),
"<pre><code>&lt;details&gt;&lt;summary&gt;x&lt;/summary&gt;"
. "y&lt;/details&gt;\n</code></pre>\n",
'Disclosure tags in a code block stay literal'
);

is(
$parser->render_html('Use `<details>` to fold.'),
"<p>Use <code>&lt;details&gt;</code> to fold.</p>\n",
'Disclosure tags in a code span stay literal'
);

like(
$parser->render_html('<details open onclick="x">nope'),
qr{&lt;details open onclick=&quot;x&quot;&gt;nope},
'Only the bare disclosure tags are recognized'
);

is(
$parser->render_html("\x{E000}\x{E002}nope\x{E003}\x{E001}"),
"<p>nope</p>\n",
'The internal disclosure markers cannot be forged in a comment'
);

# An unbalanced tag must not leak an unclosed element into the page.
like(
$parser->render_html("<details>\n<summary>oops</summary>\n\nrest\n"),
qr{</details>\z},
'An unclosed disclosure section is closed for us'
);

done_testing;
Loading