Support Loofah for textile (#43643):

* Add TablesortScrubber to both CommonMark and Textile Formatters. It will now be determined on the server-side whether to use tablesort.
* Migrate tablesort to Stimulus controller.
* Update tablesort to v5.7.0.
* Switch tablesort to ESM.

Patch by Takashi Kato (user:tohosaku).



git-svn-id: https://svn.redmine.org/redmine/trunk@24357 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
Marius Balteanu
2026-01-23 16:05:42 +00:00
parent 8a305ec99c
commit acf0f9019e
13 changed files with 245 additions and 67 deletions

View File

@@ -1170,7 +1170,6 @@ $(document).ready(function(){
data: "text=" + element + '&' + attachments,
success: function(data){
jstBlock.find('.wiki-preview').html(data);
setupWikiTableSortableHeader();
}
});
});
@@ -1439,7 +1438,6 @@ $(document).ready(defaultFocus);
$(document).ready(setupAttachmentDetail);
$(document).ready(setupTabs);
$(document).ready(setupFilePreviewNavigation);
$(document).ready(setupWikiTableSortableHeader);
$(document).on('focus', '[data-auto-complete=true]', function(event) {
inlineAutoComplete(event.target);
});

View File

@@ -1801,9 +1801,6 @@ module ApplicationHelper
'rails-ujs',
'tribute-5.1.3.min'
)
if Setting.wiki_tablesort_enabled?
tags << javascript_include_tag('tablesort-5.2.1.min.js', 'tablesort-5.2.1.number.min.js')
end
tags << javascript_include_tag('application-legacy', 'responsive')
unless User.current.pref.warn_on_leaving_unsaved == '0'
warn_text = escape_javascript(l(:text_warn_on_leaving_unsaved))

View File

@@ -0,0 +1,18 @@
/**
* Redmine - project management software
* Copyright (C) 2006- Jean-Philippe Lang
* This code is released under the GNU General Public License.
*/
import { Controller } from "@hotwired/stimulus"
import Tablesort from 'tablesort';
import numberPlugin from 'tablesort.number';
// Extensions must be loaded explicitly
Tablesort.extend(numberPlugin.name, numberPlugin.pattern, numberPlugin.sort);
// Connects to data-controller="tablesort"
export default class extends Controller {
connect() {
new Tablesort(this.element);
}
}

View File

@@ -14,7 +14,6 @@
} else {
journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
}
setupWikiTableSortableHeader();
setupCopyButtonsToPreElements();
setupHoverTooltips();
<% end %>

View File

@@ -7,3 +7,5 @@ pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin "turndown" # @7.2.0
pin_all_from "app/javascript/controllers", under: "controllers"
pin "tablesort", to: "tablesort.min.js"
pin "tablesort.number", to: "tablesort.number.min.js"

View File

@@ -55,6 +55,7 @@ module Redmine
SANITIZER = SanitizationFilter.new
SCRUBBERS = [
SyntaxHighlightScrubber.new,
Redmine::WikiFormatting::TablesortScrubber.new,
FixupAutoLinksScrubber.new,
ExternalLinksScrubber.new,
AlertsIconsScrubber.new

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006- Jean-Philippe Lang
# This code is released under the GNU General Public License.
module Redmine
module WikiFormatting
class TablesortScrubber < Loofah::Scrubber
def scrub(node)
return if !Setting.wiki_tablesort_enabled? || node.name != 'table'
rows = node.search('tr')
return if rows.size < 3
tr = rows.first
if tr.search('th').present?
node['data-controller'] = 'tablesort'
tr['data-sort-method'] = 'none'
tr.search('td').each do |td|
td['data-sort-method'] = 'none'
end
end
end
end
end
end

View File

@@ -20,11 +20,33 @@
module Redmine
module WikiFormatting
module Textile
class Formatter < RedCloth3
include ActionView::Helpers::TagHelper
include Redmine::WikiFormatting::LinksHelper
SCRUBBERS = [
Redmine::WikiFormatting::TablesortScrubber.new
]
class Formatter
include Redmine::WikiFormatting::SectionHelper
extend Forwardable
def_delegators :@filter, :extract_sections, :rip_offtags
def initialize(args)
@filter = Filter.new(args)
end
def to_html(*rules)
html = @filter.to_html(rules)
fragment = Loofah.html5_fragment(html)
SCRUBBERS.each do |scrubber|
fragment.scrub!(scrubber)
end
fragment.to_s
end
end
class Filter < RedCloth3
include Redmine::WikiFormatting::LinksHelper
alias :inline_auto_link :auto_link!
alias :inline_auto_mailto :auto_mailto!
alias :inline_restore_redmine_links :restore_redmine_links
@@ -41,7 +63,7 @@ module Redmine
def to_html(*rules)
@toc = []
super(*RULES).to_s
super(*RULES)
end
def extract_sections(index)

View File

@@ -80,7 +80,7 @@ class ApplicationHelperTest < Redmine::HelperTest
'(see <a href="http://www.foo.bar/Test" class="external">inline link</a>).',
'www.foo.bar' => '<a class="external" href="http://www.foo.bar">www.foo.bar</a>',
'http://foo.bar/page?p=1&t=z&s=' =>
'<a class="external" href="http://foo.bar/page?p=1&#38;t=z&#38;s=">http://foo.bar/page?p=1&#38;t=z&#38;s=</a>',
'<a class="external" href="http://foo.bar/page?p=1&amp;t=z&amp;s=">http://foo.bar/page?p=1&amp;t=z&amp;s=</a>',
'http://foo.bar/page#125' => '<a class="external" href="http://foo.bar/page#125">http://foo.bar/page#125</a>',
'http://foo@www.bar.com' => '<a class="external" href="http://foo@www.bar.com">http://foo@www.bar.com</a>',
'http://foo:bar@www.bar.com' => '<a class="external" href="http://foo:bar@www.bar.com">http://foo:bar@www.bar.com</a>',
@@ -92,7 +92,7 @@ class ApplicationHelperTest < Redmine::HelperTest
'<a class="external" href="http://example.net/path!602815048C7B5C20!302.html">' \
'http://example.net/path!602815048C7B5C20!302.html</a>',
# escaping
'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo&quot;bar</a>',
'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo"bar</a>',
# wrap in angle brackets
'<http://foo.bar>' => '&lt;<a class="external" href="http://foo.bar">http://foo.bar</a>&gt;',
# invalid urls
@@ -130,22 +130,22 @@ class ApplicationHelperTest < Redmine::HelperTest
def test_inline_images
to_test = {
'!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="" />',
'!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="">',
'floating !>http://foo.bar/image.jpg!' =>
'floating <span style="float:right"><img src="http://foo.bar/image.jpg" alt="" /></span>',
'floating <span style="float:right"><img src="http://foo.bar/image.jpg" alt=""></span>',
'with class !(some-class)http://foo.bar/image.jpg!' =>
'with class <img src="http://foo.bar/image.jpg" class="wiki-class-some-class" alt="" />',
'with class <img src="http://foo.bar/image.jpg" class="wiki-class-some-class" alt="">',
'with class !(wiki-class-foo)http://foo.bar/image.jpg!' =>
'with class <img src="http://foo.bar/image.jpg" class="wiki-class-foo" alt="" />',
'with class <img src="http://foo.bar/image.jpg" class="wiki-class-foo" alt="">',
'with style !{width:100px;height:100px}http://foo.bar/image.jpg!' =>
'with style <img src="http://foo.bar/image.jpg" style="width:100px;height:100px;" alt="" />',
'with style <img src="http://foo.bar/image.jpg" style="width:100px;height:100px;" alt="">',
'with title !http://foo.bar/image.jpg(This is a title)!' =>
'with title <img src="http://foo.bar/image.jpg" title="This is a title" alt="This is a title" />',
'with title <img src="http://foo.bar/image.jpg" title="This is a title" alt="This is a title">',
'with title !http://foo.bar/image.jpg(This is a double-quoted "title")!' =>
'with title <img src="http://foo.bar/image.jpg" title="This is a double-quoted &quot;title&quot;" ' \
'alt="This is a double-quoted &quot;title&quot;" />',
'alt="This is a double-quoted &quot;title&quot;">',
'with query string !http://foo.bar/image.cgi?a=1&b=2!' =>
'with query string <img src="http://foo.bar/image.cgi?a=1&#38;b=2" alt="" />'
'with query string <img src="http://foo.bar/image.cgi?a=1&amp;b=2" alt="">'
}
with_settings :text_formatting => 'textile' do
to_test.each {|text, result| assert_equal "<p>#{result}</p>", textilizable(text)}
@@ -161,24 +161,24 @@ class ApplicationHelperTest < Redmine::HelperTest
p=. !bar.gif!
RAW
with_settings :text_formatting => 'textile' do
assert textilizable(raw).include?('<img src="foo.png" alt="" />')
assert textilizable(raw).include?('<img src="bar.gif" alt="" />')
assert textilizable(raw).include?('<img src="foo.png" alt="">')
assert textilizable(raw).include?('<img src="bar.gif" alt="">')
end
end
def test_attached_images
to_test = {
'Inline image: !logo.gif!' =>
'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy" />',
'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy">',
'Inline image: !logo.GIF!' =>
'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy" />',
'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy">',
'Inline WebP image: !logo.webp!' =>
'Inline WebP image: <img src="/attachments/download/24/logo.webp" title="WebP image" alt="WebP image" loading="lazy" />',
'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="" />',
'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="" />',
'Inline WebP image: <img src="/attachments/download/24/logo.webp" title="WebP image" alt="WebP image" loading="lazy">',
'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="">',
'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="">',
# link image
'!logo.gif!:http://foo.bar/' =>
'<a href="http://foo.bar/"><img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy" /></a>',
'<a href="http://foo.bar/"><img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy"></a>',
}
attachments = Attachment.all
with_settings :text_formatting => 'textile' do
@@ -190,31 +190,31 @@ class ApplicationHelperTest < Redmine::HelperTest
attachments = Attachment.all
with_settings text_formatting: 'textile' do
# When alt text is set
assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?" />],
assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?">],
textilizable('!logo.gif(alt text)!', attachments: attachments)
# When alt text and style are set
assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?" style="width:100px;" />],
assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?" style="width:100px;">],
textilizable('!{width:100px}logo.gif(alt text)!', attachments: attachments)
# When alt text is not set
assert_match %r[<img src=".+?" title="This is a logo" alt="This is a logo" loading=".+?" />],
assert_match %r[<img src=".+?" title="This is a logo" alt="This is a logo" loading=".+?">],
textilizable('!logo.gif!', attachments: attachments)
# When alt text is not set and the attachment has no description
assert_match %r[<img src=".+?" alt="" loading=".+?" />],
assert_match %r[<img src=".+?" alt="" loading=".+?">],
textilizable('!testfile.PNG!', attachments: attachments)
# When no matching attachments are found
assert_match %r[<img src=".+?" alt="" />],
assert_match %r[<img src=".+?" alt="">],
textilizable('!no-match.jpg!', attachments: attachments)
assert_match %r[<img src=".+?" alt="alt text" />],
assert_match %r[<img src=".+?" alt="alt text">],
textilizable('!no-match.jpg(alt text)!', attachments: attachments)
# When no attachment is registered
assert_match %r[<img src=".+?" alt="" />],
assert_match %r[<img src=".+?" alt="">],
textilizable('!logo.gif!', attachments: [])
assert_match %r[<img src=".+?" alt="alt text" />],
assert_match %r[<img src=".+?" alt="alt text">],
textilizable('!logo.gif(alt text)!', attachments: [])
end
end
@@ -232,8 +232,8 @@ class ApplicationHelperTest < Redmine::HelperTest
RAW
with_settings :text_formatting => 'textile' do
assert textilizable(raw, :object => journal).include?("<img src=\"/attachments/download/#{attachment_1.id}/attached_on_issue.png\" alt=\"\" loading=\"lazy\" />")
assert textilizable(raw, :object => journal).include?("<img src=\"/attachments/download/#{attachment_2.id}/attached_on_journal.png\" alt=\"\" loading=\"lazy\" />")
assert textilizable(raw, :object => journal).include?("<img src=\"/attachments/download/#{attachment_1.id}/attached_on_issue.png\" alt=\"\" loading=\"lazy\">")
assert textilizable(raw, :object => journal).include?("<img src=\"/attachments/download/#{attachment_2.id}/attached_on_journal.png\" alt=\"\" loading=\"lazy\">")
end
end
@@ -245,7 +245,7 @@ class ApplicationHelperTest < Redmine::HelperTest
with_settings :text_formatting => 'textile' do
to_test.each do |filename, result|
attachment = Attachment.generate!(:filename => filename)
assert_include %(<img src="/attachments/download/#{attachment.id}/#{result}" alt="" loading="lazy" />),
assert_include %(<img src="/attachments/download/#{attachment.id}/#{result}" alt="" loading="lazy">),
textilizable("!#{filename}!", :attachments => [attachment])
end
end
@@ -272,7 +272,7 @@ class ApplicationHelperTest < Redmine::HelperTest
with_settings :text_formatting => 'textile' do
assert_equal(
%(<p><img src="/attachments/download/#{attachment.id}/image@2x.png" ) +
%(srcset="/attachments/download/#{attachment.id}/image@2x.png 2x" alt="" loading="lazy" /></p>),
%(srcset="/attachments/download/#{attachment.id}/image@2x.png 2x" alt="" loading="lazy"></p>),
textilizable("!image@2x.png!", :attachments => [attachment])
)
end
@@ -325,13 +325,13 @@ class ApplicationHelperTest < Redmine::HelperTest
to_test = {
'Inline image: !testtest.jpg!' =>
'Inline image: <img src="/attachments/download/' + a1.id.to_s + '/testtest.JPG" alt="" loading="lazy" />',
'Inline image: <img src="/attachments/download/' + a1.id.to_s + '/testtest.JPG" alt="" loading="lazy">',
'Inline image: !testtest.jpeg!' =>
'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testtest.jpeg" alt="" loading="lazy" />',
'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testtest.jpeg" alt="" loading="lazy">',
'Inline image: !testtest.jpe!' =>
'Inline image: <img src="/attachments/download/' + a3.id.to_s + '/testtest.JPE" alt="" loading="lazy" />',
'Inline image: <img src="/attachments/download/' + a3.id.to_s + '/testtest.JPE" alt="" loading="lazy">',
'Inline image: !testtest.bmp!' =>
'Inline image: <img src="/attachments/download/' + a4.id.to_s + '/Testtest.BMP" alt="" loading="lazy" />',
'Inline image: <img src="/attachments/download/' + a4.id.to_s + '/Testtest.BMP" alt="" loading="lazy">',
}
attachments = [a1, a2, a3, a4]
@@ -356,9 +356,9 @@ class ApplicationHelperTest < Redmine::HelperTest
to_test = {
'Inline image: !testfile.png!' =>
'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" loading="lazy" />',
'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" loading="lazy">',
'Inline image: !Testfile.PNG!' =>
'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" loading="lazy" />',
'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" loading="lazy">',
}
attachments = [a1, a2]
with_settings :text_formatting => 'textile' do
@@ -378,7 +378,7 @@ class ApplicationHelperTest < Redmine::HelperTest
"This is not a \"Link\":\n\nAnother paragraph" => "This is not a \"Link\":</p>\n\n\n\t<p>Another paragraph",
# no multiline link text
"This is a double quote \"on the first line\nand another on a second line\":test" =>
"This is a double quote \"on the first line<br />and another on a second line\":test",
"This is a double quote \"on the first line<br>and another on a second line\":test",
# mailto link
"\"system administrator\":mailto:sysadmin@example.com?subject=redmine%20permissions" =>
"<a href=\"mailto:sysadmin@example.com?subject=redmine%20permissions\">system administrator</a>",
@@ -391,7 +391,7 @@ class ApplicationHelperTest < Redmine::HelperTest
'(see "inline link":http://www.foo.bar/Test-)' =>
'(see <a href="http://www.foo.bar/Test-" class="external">inline link</a>)',
'http://foo.bar/page?p=1&t=z&s=-' =>
'<a class="external" href="http://foo.bar/page?p=1&#38;t=z&#38;s=-">http://foo.bar/page?p=1&#38;t=z&#38;s=-</a>',
'<a class="external" href="http://foo.bar/page?p=1&amp;t=z&amp;s=-">http://foo.bar/page?p=1&amp;t=z&amp;s=-</a>',
'This is an intern "link":/foo/bar-' => 'This is an intern <a href="/foo/bar-">link</a>'
}
with_settings :text_formatting => 'textile' do
@@ -1317,10 +1317,10 @@ class ApplicationHelperTest < Redmine::HelperTest
def test_html_tags
to_test = {
"<div>content</div>" => "<p>&lt;div&gt;content&lt;/div&gt;</p>",
"<div class=\"bold\">content</div>" => "<p>&lt;div class=&quot;bold&quot;&gt;content&lt;/div&gt;</p>",
"<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
"<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</p>",
# do not escape pre/code tags
"<pre>\nline 1\nline2</pre>" => "<pre>\nline 1\nline2</pre>",
"<pre>\nline 1\nline2</pre>" => "<pre>line 1\nline2</pre>",
"<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
"<pre><div class=\"foo\">content</div></pre>" => "<pre>&lt;div class=\"foo\"&gt;content&lt;/div&gt;</pre>",
"<pre><div class=\"<foo\">content</div></pre>" => "<pre>&lt;div class=\"&lt;foo\"&gt;content&lt;/div&gt;</pre>",
@@ -1477,7 +1477,7 @@ class ApplicationHelperTest < Redmine::HelperTest
"</tr><tr><td>Cell 21</td><td>#{link3}</td></tr>"
@project = Project.find(1)
with_settings :text_formatting => 'textile' do
assert_equal "<table>#{result}</table>", textilizable(text).gsub(/[\t\n]/, '')
assert_equal "<table><tbody>#{result}</tbody></table>", textilizable(text).gsub(/[\t\n]/, '')
end
end
@@ -1498,7 +1498,7 @@ class ApplicationHelperTest < Redmine::HelperTest
def test_wiki_horizontal_rule
with_settings :text_formatting => 'textile' do
assert_equal '<hr />', textilizable('---')
assert_equal '<hr>', textilizable('---')
assert_equal '<p>Dashes: ---</p>', textilizable('Dashes: ---')
end
end

View File

@@ -0,0 +1,92 @@
# frozen_string_literal: true
# Redmine - project management software
# Copyright (C) 2006- Jean-Philippe Lang
# This code is released under the GNU General Public License.
require_relative '../../../../test_helper'
class Redmine::WikiFormatting::TablesortScrubberTest < ActiveSupport::TestCase
def filter(html)
fragment = Redmine::WikiFormatting::HtmlParser.parse(html)
scrubber = Redmine::WikiFormatting::TablesortScrubber.new
fragment.scrub!(scrubber)
fragment.to_s
end
test 'should not add data-controller attribute by default' do
table = <<~HTML
<table>
<tbody><tr>
<th>A</th>
<th>B</th>
</tr>
<tr>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td></td>
</tr>
</tbody></table>
HTML
assert_equal table, filter(table)
end
test 'should not add data-controller attribute when the table has less than 3 rows' do
table = <<~HTML
<table>
<tbody><tr>
<th>A</th>
<th>B</th>
</tr>
<tr>
<td></td>
<td></td>
</tr>
</tbody></table>
HTML
with_settings :wiki_tablesort_enabled => 1 do
assert_equal table, filter(table)
end
end
test 'should add data-controller attribute when the table contains at least 3 rows and enables sorting' do
input = <<~HTML
<table>
<tbody><tr>
<th>A</th>
<th>B</th>
</tr>
<tr>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td></td>
</tr>
</tbody></table>
HTML
expected = <<~HTML
<table data-controller="tablesort">
<tbody><tr data-sort-method="none">
<th>A</th>
<th>B</th>
</tr>
<tr>
<td></td>
<td></td>
</tr>
<tr>
<td></td>
<td></td>
</tr>
</tbody></table>
HTML
with_settings :wiki_tablesort_enabled => 1 do
assert_equal expected, filter(input)
end
end
end

View File

@@ -252,7 +252,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
expected = <<~EXPECTED
<p>John said:</p>
<blockquote>
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.<br />
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.<br>
Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
<ul>
<li>Donec odio lorem,</li>
@@ -282,9 +282,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
<p>This is a table with empty cells:</p>
<table>
<tbody>
<tr><td>cell11</td><td>cell12</td><td></td></tr>
<tr><td>cell21</td><td></td><td>cell23</td></tr>
<tr><td>cell31</td><td>cell32</td><td>cell33</td></tr>
</tbody>
</table>
EXPECTED
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
@@ -298,9 +300,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
RAW
expected = <<~EXPECTED
<table>
<tbody>
<tr><td style="text-align:right;">right</td></tr>
<tr><td style="text-align:left;">left</td></tr>
<tr><td style="text-align:justify;">justify</td></tr>
</tbody>
</table>
EXPECTED
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
@@ -318,9 +322,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
<p>This is a table with trailing whitespace in one row:</p>
<table>
<tbody>
<tr><td>cell11</td><td>cell12</td></tr>
<tr><td>cell21</td><td>cell22</td></tr>
<tr><td>cell31</td><td>cell32</td></tr>
</tbody>
</table>
EXPECTED
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
@@ -343,21 +349,23 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
<p>This is a table with line breaks:</p>
<table>
<tbody>
<tr>
<td>cell11<br />continued</td>
<td>cell11<br>continued</td>
<td>cell12</td>
<td></td>
</tr>
<tr>
<td><del>cell21</del></td>
<td></td>
<td>cell23<br/>cell23 line2<br/>cell23 <strong>line3</strong></td>
<td>cell23<br>cell23 line2<br>cell23 <strong>line3</strong></td>
</tr>
<tr>
<td>cell31</td>
<td>cell32<br/>cell32 line2</td>
<td>cell32<br>cell32 line2</td>
<td>cell33</td>
</tr>
</tbody>
</table>
EXPECTED
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
@@ -380,18 +388,20 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
<p>This is a table with lists:</p>
<table>
<tbody>
<tr>
<td>cell11</td>
<td>cell12</td>
</tr>
<tr>
<td>cell21</td>
<td>ordered list<br /># item<br /># item 2</td>
<td>ordered list<br># item<br># item 2</td>
</tr>
<tr>
<td>cell31</td>
<td>unordered list<br />* item<br />* item 2</td>
<td>unordered list<br>* item<br>* item 2</td>
</tr>
</tbody>
</table>
EXPECTED
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
@@ -408,7 +418,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
expected =
'<p><img src="/images/comment.png&quot;onclick=' \
'&amp;#x61;&amp;#x6c;&amp;#x65;&amp;#x72;&amp;#x74;&amp;#x28;' \
'&amp;#x27;&amp;#x58;&amp;#x53;&amp;#x53;&amp;#x27;&amp;#x29;;&amp;#x22;" alt="" /></p>'
'&amp;#x27;&amp;#x58;&amp;#x53;&amp;#x53;&amp;#x27;&amp;#x29;;&amp;#x22;" alt=""></p>'
assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
end
@@ -635,7 +645,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
"<code class=\"foolang\">unsupported language</code>" =>
"<code data-language=\"foolang\">unsupported language</code>",
"<code class=\"c-k&r\">special-char language</code>" =>
"<code data-language=\"c-k&#38;r\">special-char language</code>",
"<code data-language=\"c-k&amp;r\">special-char language</code>",
},
false
)
@@ -652,11 +662,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
def test_should_prefix_class_attribute_on_tags
assert_html_output(
{
'!(foo)test.png!' => "<p><img src=\"test.png\" class=\"wiki-class-foo\" alt=\"\" /></p>",
'!(foo)test.png!' => "<p><img src=\"test.png\" class=\"wiki-class-foo\" alt=\"\"></p>",
'%(foo)test%' => "<p><span class=\"wiki-class-foo\">test</span></p>",
'p(foo). test' => "<p class=\"wiki-class-foo\">test</p>",
'|(foo). test|' =>
"<table>\n\t\t<tr>\n\t\t\t<td class=\"wiki-class-foo\">test</td>\n\t\t</tr>\n\t</table>",
"<table>\n\t\t<tbody><tr>\n\t\t\t<td class=\"wiki-class-foo\">test</td>\n\t\t</tr>\n\t</tbody></table>",
},
false
)
@@ -665,11 +675,11 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
def test_should_prefix_id_attribute_on_tags
assert_html_output(
{
'!(#foo)test.png!' => "<p><img src=\"test.png\" id=\"wiki-id-foo\" alt=\"\" /></p>",
'!(#foo)test.png!' => "<p><img src=\"test.png\" id=\"wiki-id-foo\" alt=\"\"></p>",
'%(#foo)test%' => "<p><span id=\"wiki-id-foo\">test</span></p>",
'p(#foo). test' => "<p id=\"wiki-id-foo\">test</p>",
'|(#foo). test|' =>
"<table>\n\t\t<tr>\n\t\t\t<td id=\"wiki-id-foo\">test</td>\n\t\t</tr>\n\t</table>",
"<table>\n\t\t<tbody><tr>\n\t\t\t<td id=\"wiki-id-foo\">test</td>\n\t\t</tr>\n\t</tbody></table>",
},
false
)
@@ -679,7 +689,7 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
assert_html_output(
{
'!(wiki-class-foo#wiki-id-bar)test.png!' =>
"<p><img src=\"test.png\" class=\"wiki-class-foo\" id=\"wiki-id-bar\" alt=\"\" /></p>",
"<p><img src=\"test.png\" class=\"wiki-class-foo\" id=\"wiki-id-bar\" alt=\"\"></p>",
},
false
)
@@ -711,8 +721,8 @@ class Redmine::WikiFormatting::TextileFormatterTest < ActionView::TestCase
</pree>
STR
expected = <<~EXPECTED
<p>&lt;pree&gt;<br />
This is some text<br />
<p>&lt;pree&gt;<br>
This is some text<br>
&lt;/pree&gt;</p>
EXPECTED
assert_equal expected.gsub(%r{[\r\n\t]}, ''), to_html(text).gsub(%r{[\r\n\t]}, '')

6
vendor/javascript/tablesort.min.js vendored Normal file
View File

@@ -0,0 +1,6 @@
/*!
* tablesort v5.7.0 (2026-01-03)
* http://tristen.ca/tablesort/demo/
* Copyright (c) 2026 ; Licensed MIT
*/
const m=[],v=function(n){if(!window.CustomEvent||typeof window.CustomEvent!="function"){const t=document.createEvent("CustomEvent");return t.initCustomEvent(n,!1,!1,void 0),t}else return new CustomEvent(n)},A=function(n,t){const e=t.sortAttribute||"data-sort";return n.hasAttribute(e)?n.getAttribute(e):n.textContent||n.innerText||""},C=function(n,t){return n=n.trim().toLowerCase(),t=t.trim().toLowerCase(),n===t?0:n<t?1:-1},E=function(n,t){return[].slice.call(n).find(function(e){return e.getAttribute("data-sort-column-key")===t})},x=function(n,t){return function(e,s){const i=n(e.td,s.td);return i===0?t?s.index-e.index:e.index-s.index:i}};class B{static extend(t,e,s){if(typeof e!="function"||typeof s!="function")throw new Error("Pattern and sort must be a function");m.push({name:t,pattern:e,sort:s})}constructor(t,e){if(!t||t.tagName!=="TABLE")throw new Error("Element must be a table");this.table=t,this.thead=!1,this.options=e||{};const s=this.getFirstRow(t);if(!s)return;const i=this.getDefaultSort(s);i&&(this.current=i,this.sortTable(i))}getFirstRow(t){let e;if(t.rows&&t.rows.length>0)if(t.tHead&&t.tHead.rows.length>0){for(let s=0;s<t.tHead.rows.length;s++)if(t.tHead.rows[s].getAttribute("data-sort-method")==="thead"){e=t.tHead.rows[s];break}e||(e=t.tHead.rows[t.tHead.rows.length-1]),this.thead=!0}else e=t.rows[0];return e}getDefaultSort(t){const e=i=>{this.current&&this.current!==i.target&&this.current.removeAttribute("aria-sort"),this.current=i.target,this.sortTable(i.target)};let s;for(let i=0;i<t.cells.length;i++){const l=t.cells[i];l.setAttribute("role","columnheader"),l.getAttribute("data-sort-method")!=="none"&&(l.tabIndex=0,l.addEventListener("click",e,!1),l.addEventListener("keydown",function(r){r.key==="Enter"&&(r.preventDefault(),e(r))}),l.getAttribute("data-sort-default")!==null&&(s=l))}return s}sortTable(t,e){let s=t.getAttribute("data-sort-column-key"),i=t.cellIndex,l=C,r="",f=[],c=this.thead?0:1,b=t.getAttribute("data-sort-method"),y=t.hasAttribute("data-sort-reverse"),u=t.getAttribute("aria-sort");if(this.table.dispatchEvent(v("beforeSort")),e||(u==="ascending"?u="descending":u==="descending"?u="ascending":u=!!this.options.descending!=y?"descending":"ascending",t.setAttribute("aria-sort",u)),!(this.table.rows.length<2)){if(!b){let o;for(;f.length<3&&c<this.table.tBodies[0].rows.length;)s?o=E(this.table.tBodies[0].rows[c].cells,s):o=this.table.tBodies[0].rows[c].cells[i],r=o?A(o,this.options):"",r=r.trim(),r.length>0&&f.push(r),c++;if(!f)return}for(let o=0;o<m.length;o++)if(r=m[o],b){if(r.name===b){l=r.sort;break}}else if(f.every(r.pattern)){l=r.sort;break}this.col=i;for(let o=0;o<this.table.tBodies.length;o++){let d=[],w={},h=0,p=0;if(!(this.table.tBodies[o].rows.length<2)){for(let a=0;a<this.table.tBodies[o].rows.length;a++){let g;r=this.table.tBodies[o].rows[a],r.getAttribute("data-sort-method")==="none"?w[h]=r:(s?g=E(r.cells,s):g=r.cells[this.col],d.push({tr:r,td:g?A(g,this.options):"",index:h})),h++}u==="descending"?d.sort(x(l,!0)):(d.sort(x(l,!1)),d.reverse());for(let a=0;a<h;a++)w[a]?(r=w[a],p++):r=d[a-p].tr,this.table.tBodies[o].appendChild(r)}}this.table.dispatchEvent(v("afterSort"))}}refresh(){this.current!==void 0&&this.sortTable(this.current,!0)}}export{B as default};

View File

@@ -0,0 +1,6 @@
/*!
* tablesort v5.7.0 (2026-01-03)
* http://tristen.ca/tablesort/demo/
* Copyright (c) 2026 ; Licensed MIT
*/
const r=function(n){return n.replace(/[^\-?0-9.]/g,"")},o=function(n,t){return n=parseFloat(n),t=parseFloat(t),n=isNaN(n)?0:n,t=isNaN(t)?0:t,n-t},e={name:"number",pattern:function(n){return n.match(/^[-+]?[£\x24Û¢´€]?\d+\s*([,\.]\d{0,2})/)||n.match(/^[-+]?\d+\s*([,\.]\d{0,2})?[£\x24Û¢´€]/)||n.match(/^[-+]?(\d)*-?([,\.]){0,1}-?(\d)+([E,e][\-+][\d]+)?%?$/)},sort:function(n,t){return n=r(n),t=r(t),o(t,n)}};typeof window.Tablesort<"u"&&Tablesort.extend(e.name,e.pattern,e.sort);var u=e;export{u as default};