Project

General

Profile

Patch #43643 » 0001-Add-tablesort-controller-Support-loofah-in-textile-m.patch

Takashi Kato, 2026-01-04 16:20

View differences:

app/assets/javascripts/application-legacy.js
1170 1170
      data: "text=" + element + '&' + attachments,
1171 1171
      success: function(data){
1172 1172
        jstBlock.find('.wiki-preview').html(data);
1173
        setupWikiTableSortableHeader();
1174 1173
      }
1175 1174
    });
1176 1175
  });
......
1439 1438
$(document).ready(setupAttachmentDetail);
1440 1439
$(document).ready(setupTabs);
1441 1440
$(document).ready(setupFilePreviewNavigation);
1442
$(document).ready(setupWikiTableSortableHeader);
1443 1441
$(document).on('focus', '[data-auto-complete=true]', function(event) {
1444 1442
  inlineAutoComplete(event.target);
1445 1443
});
app/helpers/application_helper.rb
1806 1806
      'rails-ujs',
1807 1807
      'tribute-5.1.3.min'
1808 1808
    )
1809
    if Setting.wiki_tablesort_enabled?
1810
      tags << javascript_include_tag('tablesort-5.2.1.min.js', 'tablesort-5.2.1.number.min.js')
1811
    end
1812 1809
    tags << javascript_include_tag('application-legacy', 'responsive')
1813 1810
    unless User.current.pref.warn_on_leaving_unsaved == '0'
1814 1811
      warn_text = escape_javascript(l(:text_warn_on_leaving_unsaved))
app/javascript/controllers/tablesort_controller.js
1
/**
2
 * Redmine - project management software
3
 * Copyright (C) 2006-  Jean-Philippe Lang
4
 * This code is released under the GNU General Public License.
5
 */
6
import { Controller } from "@hotwired/stimulus"
7
import Tablesort from 'tablesort';
8
import numberPlugin from 'tablesort.number';
9

  
10
// Extensions must be loaded explicitly
11
Tablesort.extend(numberPlugin.name, numberPlugin.pattern, numberPlugin.sort);
12

  
13
// Connects to data-controller="tablesort"
14
export default class extends Controller {
15
  connect() {
16
    new Tablesort(this.element);
17
  }
18
}
app/views/journals/update.js.erb
14 14
  } else {
15 15
    journal_header.append('<%= escape_javascript(render_journal_update_info(@journal)) %>');
16 16
  }
17
  setupWikiTableSortableHeader();
18 17
  setupCopyButtonsToPreElements();
19 18
  setupHoverTooltips();
20 19
<% end %>
config/importmap.rb
7 7
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
8 8
pin "turndown" # @7.2.0
9 9
pin_all_from "app/javascript/controllers", under: "controllers"
10
pin "tablesort", to: "tablesort.min.js"
11
pin "tablesort.number", to: "tablesort.number.min.js"
lib/redmine/wiki_formatting/common_mark/formatter.rb
55 55
      SANITIZER = SanitizationFilter.new
56 56
      SCRUBBERS = [
57 57
        SyntaxHighlightScrubber.new,
58
        Redmine::WikiFormatting::TablesortScrubber.new,
58 59
        FixupAutoLinksScrubber.new,
59 60
        ExternalLinksScrubber.new,
60 61
        AlertsIconsScrubber.new
lib/redmine/wiki_formatting/tablesort_scrubber.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
# This code is released under the GNU General Public License.
6

  
7
module Redmine
8
  module WikiFormatting
9
    class TablesortScrubber < Loofah::Scrubber
10
      def scrub(node)
11
        return if !Setting.wiki_tablesort_enabled? || node.name != 'table'
12

  
13
        rows = node.search('tr')
14
        return if rows.size < 3
15

  
16
        tr = rows.first
17
        if tr.search('th').present?
18
          node['data-controller'] = 'tablesort'
19
          tr['data-sort-method']  = 'none'
20
          tr.search('td').each do |td|
21
            td['data-sort-method'] = 'none'
22
          end
23
        end
24
      end
25
    end
26
  end
27
end
lib/redmine/wiki_formatting/textile/formatter.rb
20 20
module Redmine
21 21
  module WikiFormatting
22 22
    module Textile
23
      class Formatter < RedCloth3
24
        include ActionView::Helpers::TagHelper
25
        include Redmine::WikiFormatting::LinksHelper
23
      SCRUBBERS = [
24
        Redmine::WikiFormatting::TablesortScrubber.new
25
      ]
26

  
27
      class Formatter
26 28
        include Redmine::WikiFormatting::SectionHelper
27 29

  
30
        extend Forwardable
31
        def_delegators :@filter, :extract_sections, :rip_offtags
32

  
33
        def initialize(args)
34
          @filter = Filter.new(args)
35
        end
36

  
37
        def to_html(*rules)
38
          html = @filter.to_html(rules)
39
          fragment = Loofah.html5_fragment(html)
40
          SCRUBBERS.each do |scrubber|
41
            fragment.scrub!(scrubber)
42
          end
43
          fragment.to_s
44
        end
45
      end
46

  
47
      class Filter < RedCloth3
48
        include Redmine::WikiFormatting::LinksHelper
49

  
28 50
        alias :inline_auto_link :auto_link!
29 51
        alias :inline_auto_mailto :auto_mailto!
30 52
        alias :inline_restore_redmine_links :restore_redmine_links
......
41 63

  
42 64
        def to_html(*rules)
43 65
          @toc = []
44
          super(*RULES).to_s
66
          super(*RULES)
45 67
        end
46 68

  
47 69
        def extract_sections(index)
test/helpers/application_helper_test.rb
80 80
        '(see <a href="http://www.foo.bar/Test" class="external">inline link</a>).',
81 81
      'www.foo.bar' => '<a class="external" href="http://www.foo.bar">www.foo.bar</a>',
82 82
      'http://foo.bar/page?p=1&t=z&s=' =>
83
        '<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>',
83
        '<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>',
84 84
      'http://foo.bar/page#125' => '<a class="external" href="http://foo.bar/page#125">http://foo.bar/page#125</a>',
85 85
      'http://foo@www.bar.com' => '<a class="external" href="http://foo@www.bar.com">http://foo@www.bar.com</a>',
86 86
      'http://foo:bar@www.bar.com' => '<a class="external" href="http://foo:bar@www.bar.com">http://foo:bar@www.bar.com</a>',
......
92 92
         '<a class="external" href="http://example.net/path!602815048C7B5C20!302.html">' \
93 93
           'http://example.net/path!602815048C7B5C20!302.html</a>',
94 94
      # escaping
95
      'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo&quot;bar</a>',
95
      'http://foo"bar' => '<a class="external" href="http://foo&quot;bar">http://foo"bar</a>',
96 96
      # wrap in angle brackets
97 97
      '<http://foo.bar>' => '&lt;<a class="external" href="http://foo.bar">http://foo.bar</a>&gt;',
98 98
      # invalid urls
......
130 130

  
131 131
  def test_inline_images
132 132
    to_test = {
133
      '!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="" />',
133
      '!http://foo.bar/image.jpg!' => '<img src="http://foo.bar/image.jpg" alt="">',
134 134
      'floating !>http://foo.bar/image.jpg!' =>
135
         'floating <span style="float:right"><img src="http://foo.bar/image.jpg" alt="" /></span>',
135
         'floating <span style="float:right"><img src="http://foo.bar/image.jpg" alt=""></span>',
136 136
      'with class !(some-class)http://foo.bar/image.jpg!' =>
137
         'with class <img src="http://foo.bar/image.jpg" class="wiki-class-some-class" alt="" />',
137
         'with class <img src="http://foo.bar/image.jpg" class="wiki-class-some-class" alt="">',
138 138
      'with class !(wiki-class-foo)http://foo.bar/image.jpg!' =>
139
         'with class <img src="http://foo.bar/image.jpg" class="wiki-class-foo" alt="" />',
139
         'with class <img src="http://foo.bar/image.jpg" class="wiki-class-foo" alt="">',
140 140
      'with style !{width:100px;height:100px}http://foo.bar/image.jpg!' =>
141
         'with style <img src="http://foo.bar/image.jpg" style="width:100px;height:100px;" alt="" />',
141
         'with style <img src="http://foo.bar/image.jpg" style="width:100px;height:100px;" alt="">',
142 142
      'with title !http://foo.bar/image.jpg(This is a title)!' =>
143
         'with title <img src="http://foo.bar/image.jpg" title="This is a title" alt="This is a title" />',
143
         'with title <img src="http://foo.bar/image.jpg" title="This is a title" alt="This is a title">',
144 144
      'with title !http://foo.bar/image.jpg(This is a double-quoted "title")!' =>
145 145
        'with title <img src="http://foo.bar/image.jpg" title="This is a double-quoted &quot;title&quot;" ' \
146
          'alt="This is a double-quoted &quot;title&quot;" />',
146
          'alt="This is a double-quoted &quot;title&quot;">',
147 147
      'with query string !http://foo.bar/image.cgi?a=1&b=2!' =>
148
        'with query string <img src="http://foo.bar/image.cgi?a=1&#38;b=2" alt="" />'
148
        'with query string <img src="http://foo.bar/image.cgi?a=1&amp;b=2" alt="">'
149 149
    }
150 150
    with_settings :text_formatting => 'textile' do
151 151
      to_test.each {|text, result| assert_equal "<p>#{result}</p>", textilizable(text)}
......
161 161
      p=. !bar.gif!
162 162
    RAW
163 163
    with_settings :text_formatting => 'textile' do
164
      assert textilizable(raw).include?('<img src="foo.png" alt="" />')
165
      assert textilizable(raw).include?('<img src="bar.gif" alt="" />')
164
      assert textilizable(raw).include?('<img src="foo.png" alt="">')
165
      assert textilizable(raw).include?('<img src="bar.gif" alt="">')
166 166
    end
167 167
  end
168 168

  
169 169
  def test_attached_images
170 170
    to_test = {
171 171
      'Inline image: !logo.gif!' =>
172
         'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy" />',
172
         'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy">',
173 173
      'Inline image: !logo.GIF!' =>
174
         'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy" />',
174
         'Inline image: <img src="/attachments/download/3/logo.gif" title="This is a logo" alt="This is a logo" loading="lazy">',
175 175
      'Inline WebP image: !logo.webp!' =>
176
         'Inline WebP image: <img src="/attachments/download/24/logo.webp" title="WebP image" alt="WebP image" loading="lazy" />',
177
      'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="" />',
178
      'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="" />',
176
         'Inline WebP image: <img src="/attachments/download/24/logo.webp" title="WebP image" alt="WebP image" loading="lazy">',
177
      'No match: !ogo.gif!' => 'No match: <img src="ogo.gif" alt="">',
178
      'No match: !ogo.GIF!' => 'No match: <img src="ogo.GIF" alt="">',
179 179
      # link image
180 180
      '!logo.gif!:http://foo.bar/' =>
181
         '<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>',
181
         '<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>',
182 182
    }
183 183
    attachments = Attachment.all
184 184
    with_settings :text_formatting => 'textile' do
......
190 190
    attachments = Attachment.all
191 191
    with_settings text_formatting: 'textile' do
192 192
      # When alt text is set
193
      assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?" />],
193
      assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?">],
194 194
        textilizable('!logo.gif(alt text)!', attachments: attachments)
195 195

  
196 196
      # When alt text and style are set
197
      assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?" style="width:100px;" />],
197
      assert_match %r[<img src=".+?" title="alt text" alt="alt text" loading=".+?" style="width:100px;">],
198 198
        textilizable('!{width:100px}logo.gif(alt text)!', attachments: attachments)
199 199

  
200 200
      # When alt text is not set
201
      assert_match %r[<img src=".+?" title="This is a logo" alt="This is a logo" loading=".+?" />],
201
      assert_match %r[<img src=".+?" title="This is a logo" alt="This is a logo" loading=".+?">],
202 202
        textilizable('!logo.gif!', attachments: attachments)
203 203

  
204 204
      # When alt text is not set and the attachment has no description
205
      assert_match %r[<img src=".+?" alt="" loading=".+?" />],
205
      assert_match %r[<img src=".+?" alt="" loading=".+?">],
206 206
        textilizable('!testfile.PNG!', attachments: attachments)
207 207

  
208 208
      # When no matching attachments are found
209
      assert_match %r[<img src=".+?" alt="" />],
209
      assert_match %r[<img src=".+?" alt="">],
210 210
        textilizable('!no-match.jpg!', attachments: attachments)
211
      assert_match %r[<img src=".+?" alt="alt text" />],
211
      assert_match %r[<img src=".+?" alt="alt text">],
212 212
        textilizable('!no-match.jpg(alt text)!', attachments: attachments)
213 213

  
214 214
      # When no attachment is registered
215
      assert_match %r[<img src=".+?" alt="" />],
215
      assert_match %r[<img src=".+?" alt="">],
216 216
        textilizable('!logo.gif!', attachments: [])
217
      assert_match %r[<img src=".+?" alt="alt text" />],
217
      assert_match %r[<img src=".+?" alt="alt text">],
218 218
        textilizable('!logo.gif(alt text)!', attachments: [])
219 219
    end
220 220
  end
......
232 232
    RAW
233 233

  
234 234
    with_settings :text_formatting => 'textile' do
235
      assert textilizable(raw, :object => journal).include?("<img src=\"/attachments/download/#{attachment_1.id}/attached_on_issue.png\" alt=\"\" loading=\"lazy\" />")
236
      assert textilizable(raw, :object => journal).include?("<img src=\"/attachments/download/#{attachment_2.id}/attached_on_journal.png\" alt=\"\" loading=\"lazy\" />")
235
      assert textilizable(raw, :object => journal).include?("<img src=\"/attachments/download/#{attachment_1.id}/attached_on_issue.png\" alt=\"\" loading=\"lazy\">")
236
      assert textilizable(raw, :object => journal).include?("<img src=\"/attachments/download/#{attachment_2.id}/attached_on_journal.png\" alt=\"\" loading=\"lazy\">")
237 237
    end
238 238
  end
239 239

  
......
245 245
    with_settings :text_formatting => 'textile' do
246 246
      to_test.each do |filename, result|
247 247
        attachment = Attachment.generate!(:filename => filename)
248
        assert_include %(<img src="/attachments/download/#{attachment.id}/#{result}" alt="" loading="lazy" />),
248
        assert_include %(<img src="/attachments/download/#{attachment.id}/#{result}" alt="" loading="lazy">),
249 249
                       textilizable("!#{filename}!", :attachments => [attachment])
250 250
      end
251 251
    end
......
272 272
    with_settings :text_formatting => 'textile' do
273 273
      assert_equal(
274 274
        %(<p><img src="/attachments/download/#{attachment.id}/image@2x.png" ) +
275
          %(srcset="/attachments/download/#{attachment.id}/image@2x.png 2x" alt="" loading="lazy" /></p>),
275
          %(srcset="/attachments/download/#{attachment.id}/image@2x.png 2x" alt="" loading="lazy"></p>),
276 276
        textilizable("!image@2x.png!", :attachments => [attachment])
277 277
      )
278 278
    end
......
325 325

  
326 326
    to_test = {
327 327
      'Inline image: !testtest.jpg!' =>
328
        'Inline image: <img src="/attachments/download/' + a1.id.to_s + '/testtest.JPG" alt="" loading="lazy" />',
328
        'Inline image: <img src="/attachments/download/' + a1.id.to_s + '/testtest.JPG" alt="" loading="lazy">',
329 329
      'Inline image: !testtest.jpeg!' =>
330
        'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testtest.jpeg" alt="" loading="lazy" />',
330
        'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testtest.jpeg" alt="" loading="lazy">',
331 331
      'Inline image: !testtest.jpe!' =>
332
        'Inline image: <img src="/attachments/download/' + a3.id.to_s + '/testtest.JPE" alt="" loading="lazy" />',
332
        'Inline image: <img src="/attachments/download/' + a3.id.to_s + '/testtest.JPE" alt="" loading="lazy">',
333 333
      'Inline image: !testtest.bmp!' =>
334
        'Inline image: <img src="/attachments/download/' + a4.id.to_s + '/Testtest.BMP" alt="" loading="lazy" />',
334
        'Inline image: <img src="/attachments/download/' + a4.id.to_s + '/Testtest.BMP" alt="" loading="lazy">',
335 335
    }
336 336

  
337 337
    attachments = [a1, a2, a3, a4]
......
356 356

  
357 357
    to_test = {
358 358
      'Inline image: !testfile.png!' =>
359
        'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" loading="lazy" />',
359
        'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" loading="lazy">',
360 360
      'Inline image: !Testfile.PNG!' =>
361
        'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" loading="lazy" />',
361
        'Inline image: <img src="/attachments/download/' + a2.id.to_s + '/testfile.PNG" alt="" loading="lazy">',
362 362
    }
363 363
    attachments = [a1, a2]
364 364
    with_settings :text_formatting => 'textile' do
......
378 378
      "This is not a \"Link\":\n\nAnother paragraph" => "This is not a \"Link\":</p>\n\n\n\t<p>Another paragraph",
379 379
      # no multiline link text
380 380
      "This is a double quote \"on the first line\nand another on a second line\":test" =>
381
        "This is a double quote \"on the first line<br />and another on a second line\":test",
381
        "This is a double quote \"on the first line<br>and another on a second line\":test",
382 382
      # mailto link
383 383
      "\"system administrator\":mailto:sysadmin@example.com?subject=redmine%20permissions" =>
384 384
        "<a href=\"mailto:sysadmin@example.com?subject=redmine%20permissions\">system administrator</a>",
......
391 391
      '(see "inline link":http://www.foo.bar/Test-)' =>
392 392
        '(see <a href="http://www.foo.bar/Test-" class="external">inline link</a>)',
393 393
      'http://foo.bar/page?p=1&t=z&s=-' =>
394
        '<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>',
394
        '<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>',
395 395
      'This is an intern "link":/foo/bar-' => 'This is an intern <a href="/foo/bar-">link</a>'
396 396
    }
397 397
    with_settings :text_formatting => 'textile' do
......
1317 1317
  def test_html_tags
1318 1318
    to_test = {
1319 1319
      "<div>content</div>" => "<p>&lt;div&gt;content&lt;/div&gt;</p>",
1320
      "<div class=\"bold\">content</div>" => "<p>&lt;div class=&quot;bold&quot;&gt;content&lt;/div&gt;</p>",
1320
      "<div class=\"bold\">content</div>" => "<p>&lt;div class=\"bold\"&gt;content&lt;/div&gt;</p>",
1321 1321
      "<script>some script;</script>" => "<p>&lt;script&gt;some script;&lt;/script&gt;</p>",
1322 1322
      # do not escape pre/code tags
1323
      "<pre>\nline 1\nline2</pre>" => "<pre>\nline 1\nline2</pre>",
1323
      "<pre>\nline 1\nline2</pre>" => "<pre>line 1\nline2</pre>",
1324 1324
      "<pre><code>\nline 1\nline2</code></pre>" => "<pre><code>\nline 1\nline2</code></pre>",
1325 1325
      "<pre><div class=\"foo\">content</div></pre>" => "<pre>&lt;div class=\"foo\"&gt;content&lt;/div&gt;</pre>",
1326 1326
      "<pre><div class=\"<foo\">content</div></pre>" => "<pre>&lt;div class=\"&lt;foo\"&gt;content&lt;/div&gt;</pre>",
......
1477 1477
               "</tr><tr><td>Cell 21</td><td>#{link3}</td></tr>"
1478 1478
    @project = Project.find(1)
1479 1479
    with_settings :text_formatting => 'textile' do
1480
      assert_equal "<table>#{result}</table>", textilizable(text).gsub(/[\t\n]/, '')
1480
      assert_equal "<table><tbody>#{result}</tbody></table>", textilizable(text).gsub(/[\t\n]/, '')
1481 1481
    end
1482 1482
  end
1483 1483

  
......
1498 1498

  
1499 1499
  def test_wiki_horizontal_rule
1500 1500
    with_settings :text_formatting => 'textile' do
1501
      assert_equal '<hr />', textilizable('---')
1501
      assert_equal '<hr>', textilizable('---')
1502 1502
      assert_equal '<p>Dashes: ---</p>', textilizable('Dashes: ---')
1503 1503
    end
1504 1504
  end
test/unit/lib/redmine/wiki_formatting/tablesort_scrubber_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
# This code is released under the GNU General Public License.
6

  
7
require_relative '../../../../test_helper'
8

  
9
class Redmine::WikiFormatting::TablesortScrubberTest < ActiveSupport::TestCase
10
  def filter(html)
11
    fragment = Redmine::WikiFormatting::HtmlParser.parse(html)
12
    scrubber = Redmine::WikiFormatting::TablesortScrubber.new
13
    fragment.scrub!(scrubber)
14
    fragment.to_s
15
  end
16

  
17
  test 'should not add data-controller attribute by default' do
18
    table = <<~HTML
19
      <table>
20
        <tbody><tr>
21
          <th>A</th>
22
          <th>B</th>
23
        </tr>
24
        <tr>
25
          <td></td>
26
          <td></td>
27
        </tr>
28
        <tr>
29
          <td></td>
30
          <td></td>
31
        </tr>
32
      </tbody></table>
33
    HTML
34
    assert_equal table, filter(table)
35
  end
36

  
37
  test 'should not add data-controller attribute when the table has less than 3 rows' do
38
    table = <<~HTML
39
      <table>
40
        <tbody><tr>
41
          <th>A</th>
42
          <th>B</th>
43
        </tr>
44
        <tr>
45
          <td></td>
46
          <td></td>
47
        </tr>
48
      </tbody></table>
49
    HTML
50
    with_settings :wiki_tablesort_enabled => 1 do
51
      assert_equal table, filter(table)
52
    end
53
  end
54

  
55
  test 'should add data-controller attribute when the table contains at least 3 rows and enables sorting' do
56
    input = <<~HTML
57
      <table>
58
        <tbody><tr>
59
          <th>A</th>
60
          <th>B</th>
61
        </tr>
62
        <tr>
63
          <td></td>
64
          <td></td>
65
        </tr>
66
        <tr>
67
          <td></td>
68
          <td></td>
69
        </tr>
70
      </tbody></table>
71
    HTML
72
    expected = <<~HTML
73
      <table data-controller="tablesort">
74
        <tbody><tr data-sort-method="none">
75
          <th>A</th>
76
          <th>B</th>
77
        </tr>
78
        <tr>
79
          <td></td>
80
          <td></td>
81
        </tr>
82
        <tr>
83
          <td></td>
84
          <td></td>
85
        </tr>
86
      </tbody></table>
87
    HTML
88
    with_settings :wiki_tablesort_enabled => 1 do
89
      assert_equal expected, filter(input)
90
    end
91
  end
92
end
test/unit/lib/redmine/wiki_formatting/textile_formatter_test.rb
252 252
    expected = <<~EXPECTED
253 253
      <p>John said:</p>
254 254
      <blockquote>
255
      Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.<br />
255
      Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas sed libero.<br>
256 256
      Nullam commodo metus accumsan nulla. Curabitur lobortis dui id dolor.
257 257
      <ul>
258 258
        <li>Donec odio lorem,</li>
......
282 282
      <p>This is a table with empty cells:</p>
283 283

  
284 284
      <table>
285
        <tbody>
285 286
        <tr><td>cell11</td><td>cell12</td><td></td></tr>
286 287
        <tr><td>cell21</td><td></td><td>cell23</td></tr>
287 288
        <tr><td>cell31</td><td>cell32</td><td>cell33</td></tr>
289
        </tbody>
288 290
      </table>
289 291
    EXPECTED
290 292
    assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
......
298 300
    RAW
299 301
    expected = <<~EXPECTED
300 302
      <table>
303
        <tbody>
301 304
        <tr><td style="text-align:right;">right</td></tr>
302 305
        <tr><td style="text-align:left;">left</td></tr>
303 306
        <tr><td style="text-align:justify;">justify</td></tr>
307
        </tbody>
304 308
      </table>
305 309
    EXPECTED
306 310
    assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
......
318 322
      <p>This is a table with trailing whitespace in one row:</p>
319 323

  
320 324
      <table>
325
        <tbody>
321 326
        <tr><td>cell11</td><td>cell12</td></tr>
322 327
        <tr><td>cell21</td><td>cell22</td></tr>
323 328
        <tr><td>cell31</td><td>cell32</td></tr>
329
        </tbody>
324 330
      </table>
325 331
    EXPECTED
326 332
    assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
......
343 349
      <p>This is a table with line breaks:</p>
344 350

  
345 351
      <table>
352
        <tbody>
346 353
        <tr>
347
          <td>cell11<br />continued</td>
354
          <td>cell11<br>continued</td>
348 355
          <td>cell12</td>
349 356
          <td></td>
350 357
        </tr>
351 358
        <tr>
352 359
          <td><del>cell21</del></td>
353 360
          <td></td>
354
          <td>cell23<br/>cell23 line2<br/>cell23 <strong>line3</strong></td>
361
          <td>cell23<br>cell23 line2<br>cell23 <strong>line3</strong></td>
355 362
        </tr>
356 363
        <tr>
357 364
          <td>cell31</td>
358
          <td>cell32<br/>cell32 line2</td>
365
          <td>cell32<br>cell32 line2</td>
359 366
          <td>cell33</td>
360 367
        </tr>
368
        </tbody>
361 369
      </table>
362 370
    EXPECTED
363 371
    assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
......
380 388
      <p>This is a table with lists:</p>
381 389

  
382 390
      <table>
391
        <tbody>
383 392
        <tr>
384 393
          <td>cell11</td>
385 394
          <td>cell12</td>
386 395
        </tr>
387 396
        <tr>
388 397
          <td>cell21</td>
389
          <td>ordered list<br /># item<br /># item 2</td>
398
          <td>ordered list<br># item<br># item 2</td>
390 399
        </tr>
391 400
        <tr>
392 401
          <td>cell31</td>
393
          <td>unordered list<br />* item<br />* item 2</td>
402
          <td>unordered list<br>* item<br>* item 2</td>
394 403
        </tr>
404
        </tbody>
395 405
      </table>
396 406
    EXPECTED
397 407
    assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
......
408 418
    expected =
409 419
      '<p><img src="/images/comment.png&quot;onclick=' \
410 420
        '&amp;#x61;&amp;#x6c;&amp;#x65;&amp;#x72;&amp;#x74;&amp;#x28;' \
411
        '&amp;#x27;&amp;#x58;&amp;#x53;&amp;#x53;&amp;#x27;&amp;#x29;;&amp;#x22;" alt="" /></p>'
421
        '&amp;#x27;&amp;#x58;&amp;#x53;&amp;#x53;&amp;#x27;&amp;#x29;;&amp;#x22;" alt=""></p>'
412 422
    assert_equal expected.gsub(%r{\s+}, ''), to_html(raw).gsub(%r{\s+}, '')
413 423
  end
414 424

  
......
635 645
        "<code class=\"foolang\">unsupported language</code>" =>
636 646
          "<code data-language=\"foolang\">unsupported language</code>",
637 647
        "<code class=\"c-k&r\">special-char language</code>" =>
638
          "<code data-language=\"c-k&#38;r\">special-char language</code>",
648
          "<code data-language=\"c-k&amp;r\">special-char language</code>",
639 649
      },
640 650
      false
641 651
    )
......
652 662
  def test_should_prefix_class_attribute_on_tags
653 663
    assert_html_output(
654 664
      {
655
        '!(foo)test.png!' => "<p><img src=\"test.png\" class=\"wiki-class-foo\" alt=\"\" /></p>",
665
        '!(foo)test.png!' => "<p><img src=\"test.png\" class=\"wiki-class-foo\" alt=\"\"></p>",
656 666
        '%(foo)test%'     => "<p><span class=\"wiki-class-foo\">test</span></p>",
657 667
        'p(foo). test'    => "<p class=\"wiki-class-foo\">test</p>",
658 668
        '|(foo). test|'   =>
659
           "<table>\n\t\t<tr>\n\t\t\t<td class=\"wiki-class-foo\">test</td>\n\t\t</tr>\n\t</table>",
669
           "<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>",
660 670
      },
661 671
      false
662 672
    )
......
665 675
  def test_should_prefix_id_attribute_on_tags
666 676
    assert_html_output(
667 677
      {
668
        '!(#foo)test.png!' => "<p><img src=\"test.png\" id=\"wiki-id-foo\" alt=\"\" /></p>",
678
        '!(#foo)test.png!' => "<p><img src=\"test.png\" id=\"wiki-id-foo\" alt=\"\"></p>",
669 679
        '%(#foo)test%'     => "<p><span id=\"wiki-id-foo\">test</span></p>",
670 680
        'p(#foo). test'    => "<p id=\"wiki-id-foo\">test</p>",
671 681
        '|(#foo). test|'   =>
672
           "<table>\n\t\t<tr>\n\t\t\t<td id=\"wiki-id-foo\">test</td>\n\t\t</tr>\n\t</table>",
682
           "<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>",
673 683
      },
674 684
      false
675 685
    )
......
679 689
    assert_html_output(
680 690
      {
681 691
        '!(wiki-class-foo#wiki-id-bar)test.png!' =>
682
           "<p><img src=\"test.png\" class=\"wiki-class-foo\" id=\"wiki-id-bar\" alt=\"\" /></p>",
692
           "<p><img src=\"test.png\" class=\"wiki-class-foo\" id=\"wiki-id-bar\" alt=\"\"></p>",
683 693
      },
684 694
      false
685 695
    )
......
711 721
      </pree>
712 722
    STR
713 723
    expected = <<~EXPECTED
714
      <p>&lt;pree&gt;<br />
715
        This is some text<br />
724
      <p>&lt;pree&gt;<br>
725
        This is some text<br>
716 726
      &lt;/pree&gt;</p>
717 727
    EXPECTED
718 728
    assert_equal expected.gsub(%r{[\r\n\t]}, ''), to_html(text).gsub(%r{[\r\n\t]}, '')
vendor/javascript/tablesort.min.js
1
/*!
2
 * tablesort v5.7.0 (2026-01-03)
3
 * http://tristen.ca/tablesort/demo/
4
 * Copyright (c) 2026 ; Licensed MIT
5
 */
6
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};
vendor/javascript/tablesort.number.min.js
1
/*!
2
 * tablesort v5.7.0 (2026-01-03)
3
 * http://tristen.ca/tablesort/demo/
4
 * Copyright (c) 2026 ; Licensed MIT
5
 */
6
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};
(1-1/3)