reverse_markdown 3.0.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d374ce991c236ccd344ef88d17531e3dc845bc4eb76015f5237f2646be1b3b66
4
- data.tar.gz: 3bb3e85c76d512647fe7c70f983b22113775c2ab02bc7410dec85cfd06c46cb8
3
+ metadata.gz: df79f3321ee1b7803fbb6a3b6ab689e5dd6ca149eb1398c4fb3c40f62e33342f
4
+ data.tar.gz: e33b4305f00468791b28d8544fd50c94bedc2c634378f819b6fc1c17a558d23e
5
5
  SHA512:
6
- metadata.gz: 1c860d94f4b07ae28cd2553e86c6538ebcaecd861a25bb695879ed2f69b6da6459c2ff9884d7c86127c18dac6217ddd3b41ee8e069eaa012e04b9d6d280cebd4
7
- data.tar.gz: 1025d7a735a38f59d2e921d5e7fb29e41b007a238eca7052bba16b319316695a50dc15f1729b47f2c27887244000b0c5a2b3d386be1765ee0c37578f5be2978d
6
+ metadata.gz: 2bdbe152941277e1bd891080277d8bb20bb165826ac44425159126448c559aceefb05aeed1569c09423fdfc8af1d8c6d68190814036713f53c627d966b6441cb
7
+ data.tar.gz: f94731850ae8879b8336e11ac80f4e6ac3ba93f1b793a11ab4e8be1847d0ecec0ed58e52a03d05e0961ea4d24a8b23f4857b95f23d88aadd4c7ff2edc6f13b2f
@@ -17,7 +17,7 @@ jobs:
17
17
 
18
18
  steps:
19
19
  - name: Checkout code
20
- uses: actions/checkout@v3
20
+ uses: actions/checkout@v7
21
21
 
22
22
  - name: Set up Ruby
23
23
  uses: ruby/setup-ruby@v1
data/.gitignore CHANGED
@@ -8,3 +8,5 @@ Gemfile.lock
8
8
  pkg/*
9
9
  coverage/*
10
10
  TODO
11
+ .claude/
12
+ CLAUDE.md
data/CHANGELOG.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # Change Log
2
2
  All notable changes to this project will be documented in this file.
3
3
 
4
+ ## 3.1.0 - November 2026
5
+ - Preserve spaces, blank lines and Markdown characters inside code, thanks @skatkov, see #116
6
+ - Convert Nokogiri document fragments to Markdown, thanks @skatkov, see #116
7
+ - `treat_children` returns placeholder text for code; custom converters that need the code read `node.text`, see #116
8
+ - Keep nested lists and later paragraphs in list items that start with a paragraph, thanks @skatkov, see #117
9
+ - Indent nested lists by the width of the parent marker, e.g. 3 spaces under `1.`, see #117
10
+
11
+ ## 3.0.3 - September 2026
12
+ - Make whitespace cleanup linear instead of quadratic, thanks @skatkov, see #114
13
+ - Only force encoding on raw string input, thanks @FoboCasteR, see #112
14
+ - Remove simplecov deprecation warnings and refresh dev dependencies, thanks @skatkov, see #115
15
+
4
16
  ## 3.0.2 - January 2026
5
17
  - Add support for ruby 4.0
6
18
  - Some cleanup of test cases, thanks @joelhawksley, see #107
data/README.md CHANGED
@@ -39,6 +39,7 @@ gem 'reverse_markdown'
39
39
  - Module based - if you miss a tag, just add it
40
40
  - Can deal with nested lists
41
41
  - Inline and block code is supported
42
+ - Prose cleanup does not collapse spaces, tabs, or blank lines inside code
42
43
  - Supports blockquote
43
44
 
44
45
 
@@ -18,11 +18,13 @@ module ReverseMarkdown
18
18
  end
19
19
 
20
20
  def remove_inner_whitespaces(string)
21
- string.each_line.inject("") do |memo, line|
22
- memo + preserve_border_whitespaces(line) do
21
+ result = String.new(encoding: Encoding::UTF_8)
22
+ string.each_line do |line|
23
+ result << preserve_border_whitespaces(line) do
23
24
  line.strip.gsub(/[ \t]{2,}/, ' ')
24
25
  end
25
26
  end
27
+ result
26
28
  end
27
29
 
28
30
  # Find non-asterisk content that is enclosed by two or
@@ -43,6 +43,13 @@ module ReverseMarkdown
43
43
  def extract_src(node)
44
44
  node['src'].to_s.empty? ? '' : node['src'].to_s
45
45
  end
46
+
47
+ private
48
+
49
+ def preserve_literal(content, state)
50
+ literals = state[:literal_content]
51
+ literals ? literals.protect(content) : content
52
+ end
46
53
  end
47
54
  end
48
55
  end
@@ -7,6 +7,7 @@ module ReverseMarkdown
7
7
  end
8
8
 
9
9
  register :document, Bypass.new
10
+ register :'#document-fragment', Bypass.new
10
11
  register :html, Bypass.new
11
12
  register :body, Bypass.new
12
13
  register :span, Bypass.new
@@ -2,7 +2,7 @@ module ReverseMarkdown
2
2
  module Converters
3
3
  class Code < Base
4
4
  def convert(node, state = {})
5
- "`#{node.text}`"
5
+ preserve_literal("`#{node.text}`", state)
6
6
  end
7
7
  end
8
8
 
@@ -3,12 +3,16 @@ module ReverseMarkdown
3
3
  class Li < Base
4
4
  def convert(node, state = {})
5
5
  contains_child_paragraph = node.first_element_child ? node.first_element_child.name == 'p' : false
6
- content_node = contains_child_paragraph ? node.first_element_child : node
7
- content = treat_children(content_node, state)
8
- indentation = indentation_from(state)
6
+ content = treat_children(node, state)
7
+ content = content.sub(/\A[ \t]*\n\n/, '') if contains_child_paragraph
9
8
  prefix = prefix_for(node)
9
+ # Kramdown requires four-space continuation indentation after an empty marker line.
10
+ continuation_indentation = content.match?(/\A[ \t]*\n/) ? ' ' : ' ' * prefix.length
11
+ content = content.lines.map.with_index do |line, index|
12
+ index.zero? || line.strip.empty? ? line : "#{continuation_indentation}#{line}"
13
+ end.join
10
14
 
11
- "#{indentation}#{prefix}#{content.chomp}\n" +
15
+ "#{prefix}#{content.chomp}\n" +
12
16
  (contains_child_paragraph ? "\n" : '')
13
17
  end
14
18
 
@@ -20,11 +24,6 @@ module ReverseMarkdown
20
24
  '- '
21
25
  end
22
26
  end
23
-
24
- def indentation_from(state)
25
- length = state.fetch(:ol_count, 0)
26
- ' ' * [length - 1, 0].max
27
- end
28
27
  end
29
28
 
30
29
  register :li, Li.new
@@ -2,8 +2,7 @@ module ReverseMarkdown
2
2
  module Converters
3
3
  class Ol < Base
4
4
  def convert(node, state = {})
5
- ol_count = state.fetch(:ol_count, 0) + 1
6
- +"\n" << treat_children(node, state.merge(ol_count: ol_count)) << "\n"
5
+ +"\n" << treat_children(node, state) << "\n"
7
6
  end
8
7
  end
9
8
 
@@ -2,25 +2,27 @@ module ReverseMarkdown
2
2
  module Converters
3
3
  class Pre < Base
4
4
  def convert(node, state = {})
5
- content = treat_children(node, state)
5
+ content = treat_children(node, state).strip
6
6
  if ReverseMarkdown.config.github_flavored
7
- +"\n```#{language(node)}\n" << content.strip << "\n```\n"
7
+ code = "```#{language(node)}\n#{content}\n```"
8
+ "\n#{preserve_literal(code, state)}\n"
8
9
  else
9
- +"\n\n " << content.lines.to_a.join(" ") << "\n\n"
10
+ code = " #{content.lines.to_a.join(' ')}"
11
+ "\n\n#{preserve_literal(code, state)}\n\n"
10
12
  end
11
13
  end
12
14
 
13
15
  private
14
16
 
15
- # Override #treat as proposed in https://github.com/xijo/reverse_markdown/pull/69
17
+ # Highlighting wrappers are not prose: recurse without normalizing or
18
+ # escaping their text, while preserving explicit HTML line breaks.
16
19
  def treat(node, state)
17
- case node.name
18
- when 'code', 'text'
19
- node.text.strip
20
- when 'br'
20
+ if node.text? || node.cdata?
21
+ node.text
22
+ elsif node.name == 'br'
21
23
  "\n"
22
24
  else
23
- super
25
+ treat_children(node, state)
24
26
  end
25
27
  end
26
28
 
@@ -0,0 +1,29 @@
1
+ module ReverseMarkdown
2
+ # Conversion-local storage for code that must bypass prose cleanup. Capture
3
+ # each line separately so container converters can still indent and quote it.
4
+ class LiteralContent
5
+ # NUL is not valid HTML text. Letter guards keep internal markers
6
+ # intact when surrounding converters strip whitespace from their output.
7
+ MARKER = /RMC\0(\d+)\0END/
8
+
9
+ def initialize
10
+ @values = []
11
+ end
12
+
13
+ def protect(content)
14
+ content.lines("\n").map do |line|
15
+ marker = "RMC\0#{@values.length}\0END"
16
+ @values << line.delete_suffix("\n")
17
+ line.end_with?("\n") ? "#{marker}\n" : marker
18
+ end.join
19
+ end
20
+
21
+ def restore(markdown)
22
+ return markdown if @values.empty?
23
+
24
+ markdown.gsub(MARKER) { @values.fetch(Regexp.last_match(1).to_i) }
25
+ end
26
+ end
27
+
28
+ private_constant :LiteralContent
29
+ end
@@ -1,3 +1,3 @@
1
1
  module ReverseMarkdown
2
- VERSION = '3.0.2'
2
+ VERSION = '3.1.0'
3
3
  end
@@ -3,6 +3,7 @@ require 'reverse_markdown/version'
3
3
  require 'reverse_markdown/errors'
4
4
  require 'reverse_markdown/cleaner'
5
5
  require 'reverse_markdown/config'
6
+ require 'reverse_markdown/literal_content'
6
7
  require 'reverse_markdown/converters'
7
8
  require 'reverse_markdown/converters/base'
8
9
  require 'reverse_markdown/converters/a'
@@ -37,18 +38,17 @@ module ReverseMarkdown
37
38
 
38
39
  def self.convert(input, options = {})
39
40
  config.with(options) do
40
- input = cleaner.force_encoding(input.to_s)
41
-
42
41
  root = case input
43
- when String then Nokogiri::HTML(input).root
42
+ when String then Nokogiri::HTML(cleaner.force_encoding(input.to_s)).root
44
43
  when Nokogiri::XML::Document then input.root
45
44
  when Nokogiri::XML::Node then input
46
45
  end
47
46
 
48
47
  root or return ''
49
48
 
50
- result = ReverseMarkdown::Converters.lookup(root.name).convert(root)
51
- cleaner.tidy(result)
49
+ literals = LiteralContent.new
50
+ result = ReverseMarkdown::Converters.lookup(root.name).convert(root, literal_content: literals)
51
+ literals.restore(cleaner.tidy(result))
52
52
  end
53
53
  end
54
54
 
@@ -23,5 +23,6 @@ Gem::Specification.new do |s|
23
23
  s.add_development_dependency 'simplecov'
24
24
  s.add_development_dependency 'rake'
25
25
  s.add_development_dependency 'kramdown'
26
+ s.add_development_dependency 'ostruct'
26
27
  s.add_development_dependency 'debug' unless RUBY_ENGINE == 'jruby'
27
28
  end
@@ -6,4 +6,12 @@ describe ReverseMarkdown do
6
6
  subject { ReverseMarkdown.convert(input) }
7
7
 
8
8
  it { is_expected.to eq("naked text 1\n\nparagraph text\n\nnaked text 2") }
9
+
10
+ it 'converts a Nokogiri document fragment through its child converters' do
11
+ fragment = Nokogiri::HTML.fragment('<h1>Title</h1><p>Some <strong>text</strong>.</p>')
12
+ original = fragment.to_html
13
+
14
+ expect(ReverseMarkdown.convert(fragment)).to eq ReverseMarkdown.convert(original)
15
+ expect(fragment.to_html).to eq original
16
+ end
9
17
  end
@@ -11,8 +11,8 @@ describe ReverseMarkdown do
11
11
  it { is_expected.to match /\n1. ordered list entry\n/ }
12
12
  it { is_expected.to match /\n2. ordered list entry 2\n/ }
13
13
  it { is_expected.to match /\n1. list entry 1st hierarchy\n/ }
14
- it { is_expected.to match /\n {2}- nested unsorted list entry\n/ }
15
- it { is_expected.to match /\n {4}1. deep nested list entry\n/ }
14
+ it { is_expected.to match /\n {4}- nested unsorted list entry\n/ }
15
+ it { is_expected.to match /\n {8}1. deep nested list entry\n/ }
16
16
 
17
17
  context "nested list with no whitespace" do
18
18
  it { is_expected.to match /\n- item a\n/ }
@@ -40,18 +40,61 @@ describe ReverseMarkdown do
40
40
  end
41
41
 
42
42
  context "list item containing multiple <p> tags" do
43
- xit { is_expected.to match /\n- li 1, p 1\n\n- li 1, p 2\n/ }
43
+ it { is_expected.to include "- li 1, p 1\n\n li 1, p 2\n\n- li 2, p 1\n" }
44
+ end
45
+
46
+ context "paragraphs followed by nested lists" do
47
+ let(:input) do
48
+ '<ul><li><p>Source:</p><ul><li><p>Readable path.</p></li>' \
49
+ '<li><p>Readable IO.</p></li></ul><p>More details.</p></li>' \
50
+ '<li><p>Destination:</p><ul><li><p>Writable path.</p></li></ul></li></ul>'
51
+ end
52
+
53
+ it "preserves every paragraph and nested item" do
54
+ is_expected.to eq "- Source:\n\n - Readable path.\n\n - Readable IO.\n\n" \
55
+ " More details.\n\n- Destination:\n\n - Writable path.\n\n"
56
+ end
57
+ end
58
+
59
+ context "paragraphs followed by nested ordered lists" do
60
+ let(:input) do
61
+ '<ol><li><p>First:</p><ol><li><p>Child one.</p></li>' \
62
+ '<li><p>Child two.</p></li></ol></li><li><p>Second.</p></li></ol>'
63
+ end
64
+
65
+ it "indents nested items past the parent marker" do
66
+ is_expected.to eq "1. First:\n\n 1. Child one.\n\n 2. Child two.\n\n2. Second.\n\n"
67
+ end
68
+ end
69
+
70
+ context "an empty paragraph followed by a nested list" do
71
+ let(:input) { '<ul><li><p></p><ul><li>Child.</li></ul></li></ul>' }
72
+
73
+ it "keeps the child list below the empty parent marker" do
74
+ is_expected.to eq "- \n\n - Child.\n\n"
75
+ end
76
+ end
77
+
78
+ context "nested lists after a two-digit marker" do
79
+ let(:input) do
80
+ '<ol>' + '<li>Item.</li>' * 9 +
81
+ '<li><p>Tenth:</p><ul><li><p>Child.</p></li></ul></li></ol>'
82
+ end
83
+
84
+ it "accounts for the full marker width" do
85
+ is_expected.to include "10. Tenth:\n\n - Child.\n"
86
+ end
44
87
  end
45
88
 
46
89
  context 'it produces correct numbering' do
47
90
  it { is_expected.to include "1. one" }
48
- it { is_expected.to include " 1. one one" }
49
- it { is_expected.to include " 2. one two" }
91
+ it { is_expected.to include "\n 1. one one" }
92
+ it { is_expected.to include "\n 2. one two" }
50
93
  it { is_expected.to include "2. two" }
51
- it { is_expected.to include " 1. two one" }
52
- it { is_expected.to include " 1. two one one" }
53
- it { is_expected.to include " 2. two one two" }
54
- it { is_expected.to include " 2. two two" }
94
+ it { is_expected.to include "\n 1. two one" }
95
+ it { is_expected.to include "\n 1. two one one" }
96
+ it { is_expected.to include "\n 2. two one two" }
97
+ it { is_expected.to include "\n 2. two two" }
55
98
  it { is_expected.to include "3. three" }
56
99
  end
57
100
 
@@ -0,0 +1,175 @@
1
+ require 'spec_helper'
2
+ require 'kramdown'
3
+
4
+ describe ReverseMarkdown, 'literal code content' do
5
+ def pre_html(content)
6
+ escaped = content.gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
7
+ "<pre>#{escaped}</pre>"
8
+ end
9
+
10
+ it 'preserves spaces, tabs, and Markdown-looking text in inline code' do
11
+ code = "two spaces\tand __underscores__ **.** [ spaced ]"
12
+
13
+ result = ReverseMarkdown.convert("<p>Copy <code>#{code}</code>.</p>")
14
+
15
+ expect(result).to include "Copy `#{code}`."
16
+ end
17
+
18
+ it 'preserves empty code without leaving internal markers in the output' do
19
+ result = ReverseMarkdown.convert('<p><code></code></p><pre></pre>', github_flavored: true)
20
+
21
+ expect(result).to include '``'
22
+ expect(result).to include "```\n\n```"
23
+ expect(result).not_to include "\0"
24
+ end
25
+
26
+ it 'preserves literal HTML, ERB, and entity strings in code blocks' do
27
+ code = "template = '<b><%= name %></b> &lt;tag&gt; &amp;'"
28
+
29
+ result = ReverseMarkdown.convert(pre_html(code), github_flavored: true)
30
+
31
+ expect(result).to include "```\n#{code}\n```"
32
+ end
33
+
34
+ it 'preserves inline code through list converter state changes' do
35
+ html = '<ol><li>Copy <code>two spaces [ spaced ]</code></li>' \
36
+ '<li><code>second value</code></li></ol>'
37
+
38
+ result = ReverseMarkdown.convert(html)
39
+
40
+ expect(result).to include '1. Copy `two spaces [ spaced ]`'
41
+ expect(result).to include '2. `second value`'
42
+ end
43
+
44
+ [false, true].each do |github_flavored|
45
+ context "with github_flavored: #{github_flavored}" do
46
+ it 'preserves spaces and tabs inside code while still cleaning prose' do
47
+ code = "puts 'two spaces'\nvalue = 'a\tb'\nputs '__name__ **.** [ spaced ]'"
48
+ html = "<p>before prose</p>#{pre_html(code)}<p>after prose</p>"
49
+
50
+ result = ReverseMarkdown.convert(html, github_flavored: github_flavored)
51
+ expected = github_flavored ? "```\n#{code}\n```" : code.lines.map { |line| " #{line}" }.join
52
+
53
+ expect(result).to include expected
54
+ expect(result).to include 'before prose'
55
+ expect(result).to include 'after prose'
56
+ end
57
+
58
+ it 'preserves repeated blank lines in code blocks' do
59
+ code = "puts :before\n\n\n\nputs :after"
60
+
61
+ result = ReverseMarkdown.convert(pre_html(code), github_flavored: github_flavored)
62
+ expected = github_flavored ? "```\n#{code}\n```" : code.lines.map { |line| " #{line}" }.join
63
+
64
+ expect(result).to include expected
65
+ end
66
+
67
+ it 'preserves code in nested blockquotes through container cleanup' do
68
+ code = "marker = '```'\nputs 'two spaces'\n\n\nputs '__name__ [ spaced ]'"
69
+ html = "<blockquote><blockquote>#{pre_html(code)}</blockquote></blockquote>"
70
+
71
+ result = ReverseMarkdown.convert(html, github_flavored: github_flavored)
72
+ expected = github_flavored ? "```\n#{code}\n```" : code.lines.map { |line| " #{line}" }.join
73
+
74
+ expect(result).to include expected.lines.map { |line| "> > #{line}" }.join
75
+ end
76
+ end
77
+ end
78
+
79
+ it 'preserves highlighted code as literal text, including nested line breaks' do
80
+ html = '<pre><code><span class="name">value</span> = ' \
81
+ '<span class="string">\'two spaces &amp;lt;tag&amp;gt;\'</span>' \
82
+ '<br><span class="name">puts</span> value</code></pre>'
83
+
84
+ result = ReverseMarkdown.convert(html, github_flavored: true)
85
+
86
+ expect(result).to include "```\nvalue = 'two spaces &lt;tag&gt;'\nputs value\n```"
87
+ end
88
+
89
+ it 'does not confuse prose joined across inline elements with literal content' do
90
+ html = '<p><span>RMC</span><span>0END</span> X0End XX0End</p>' \
91
+ '<pre>puts :literal</pre>'
92
+
93
+ result = ReverseMarkdown.convert(html, github_flavored: true)
94
+
95
+ expect(result).to include 'RMC0END X0End XX0End'
96
+ expect(result).to include "```\nputs :literal\n```"
97
+ expect(result).not_to include "\0"
98
+ end
99
+
100
+ it 'does not treat invalid HTML NUL references as internal markers' do
101
+ html = '<p>RMC&#0;0&#0;END</p><pre>puts :literal</pre>'
102
+ prose = Nokogiri::HTML.fragment(html).at_css('p').text
103
+
104
+ result = ReverseMarkdown.convert(html, github_flavored: true)
105
+
106
+ expect(result).to include prose
107
+ expect(result).to include "```\nputs :literal\n```"
108
+ expect(result).not_to include "\0"
109
+ end
110
+
111
+ it 'preserves literal CDATA content from an XML document' do
112
+ code = "value = 'a b <tag> &amp;'"
113
+ document = Nokogiri::XML("<pre><![CDATA[#{code}]]></pre>")
114
+
115
+ result = ReverseMarkdown.convert(document, github_flavored: true)
116
+
117
+ expect(result).to include "```\n#{code}\n```"
118
+ end
119
+
120
+ it 'preserves Unicode code without changing its encoding' do
121
+ code = "puts 'café 世界'"
122
+
123
+ result = ReverseMarkdown.convert(pre_html(code), github_flavored: true)
124
+
125
+ expect(result).to include "```\n#{code}\n```"
126
+ expect(result.encoding).to eq Encoding::UTF_8
127
+ end
128
+
129
+ it 'keeps indented code valid when Markdown inside blockquotes is rendered' do
130
+ code = "puts 'two spaces'\n\n\nputs '[ spaced ] __name__'"
131
+ html = "<blockquote><blockquote>#{pre_html(code)}</blockquote></blockquote>"
132
+ markdown = ReverseMarkdown.convert(html)
133
+
134
+ rendered = Nokogiri::HTML.fragment(Kramdown::Document.new(markdown).to_html)
135
+
136
+ expect(rendered.at_css('blockquote blockquote pre code').text).to eq "#{code}\n"
137
+ end
138
+
139
+ it 'restores distinct values throughout a long code block' do
140
+ code = Array.new(180) { |index| "puts 'line #{index} value'" }.join("\n")
141
+
142
+ result = ReverseMarkdown.convert(pre_html(code), github_flavored: true)
143
+
144
+ expect(result).to include "```\n#{code}\n```"
145
+ end
146
+
147
+ it 'preserves code without modifying a Nokogiri input node' do
148
+ node = node_for(pre_html("puts 'two spaces'\n\n\nputs :done"))
149
+ original = node.to_html
150
+
151
+ result = ReverseMarkdown.convert(node, github_flavored: true)
152
+
153
+ expect(result).to include "```\nputs 'two spaces'\n\n\nputs :done\n```"
154
+ expect(node.to_html).to eq original
155
+ end
156
+
157
+ it 'preserves code in a native Nokogiri document fragment' do
158
+ fragment = Nokogiri::HTML.fragment("<p>Copy this:</p>#{pre_html("puts 'two spaces'\n\n\nputs :done")}")
159
+ original = fragment.to_html
160
+
161
+ result = ReverseMarkdown.convert(fragment, github_flavored: true)
162
+
163
+ expect(result).to include "```\nputs 'two spaces'\n\n\nputs :done\n```"
164
+ expect(fragment.to_html).to eq original
165
+ end
166
+
167
+ it 'keeps literal content isolated between conversions' do
168
+ first = ReverseMarkdown.convert(pre_html("puts 'first value'"), github_flavored: true)
169
+ second = ReverseMarkdown.convert(pre_html("puts 'second value'"), github_flavored: true)
170
+
171
+ expect(first).to include "puts 'first value'"
172
+ expect(second).to include "puts 'second value'"
173
+ expect(second).not_to include 'first'
174
+ end
175
+ end
@@ -62,6 +62,33 @@ describe 'Round trip: HTML to markdown (via reverse_markdown) to HTML (via redca
62
62
  ")
63
63
  end
64
64
 
65
+ it "should preserve paragraphs before and after nested lists" do
66
+ roundtrip_should_preserve(
67
+ '<ul><li><p>Source:</p><ul><li><p>Readable path.</p></li>' \
68
+ '<li><p>Readable IO.</p></li></ul><p>More details.</p></li>' \
69
+ '<li><p>Destination:</p><ul><li><p>Writable path.</p></li>' \
70
+ '<li><p>Writable IO.</p></li></ul></li></ul>'
71
+ )
72
+ end
73
+
74
+ it "should preserve mixed nested lists and their numbering" do
75
+ roundtrip_should_preserve(
76
+ '<ol><li><p>First:</p><ul><li><p>Child:</p><ol>' \
77
+ '<li><p>Grandchild one.</p></li><li><p>Grandchild two.</p></li></ol></li></ul></li>' \
78
+ '<li><p>Second.</p></li></ol>'
79
+ )
80
+ end
81
+
82
+ %w[ul ol].each do |tag|
83
+ it "should keep child lists nested below empty #{tag} items" do
84
+ input = "<#{tag}><li><p></p><ul><li>Child.</li></ul></li></#{tag}>"
85
+ output = html2markdown2html(input)
86
+ expected = "<#{tag}><li><ul><li>Child.</li></ul></li></#{tag}>"
87
+
88
+ expect(normalize_html(output)).to eq expected
89
+ end
90
+ end
91
+
65
92
  it "should preserve <hr> tags" do
66
93
  roundtrip_should_preserve("<hr />")
67
94
  end
@@ -50,6 +50,14 @@ describe ReverseMarkdown::Cleaner do
50
50
  result = cleaner.remove_inner_whitespaces("foo \nbar \n \n \nfoo")
51
51
  expect(result).to eq "foo \nbar \n \n \nfoo"
52
52
  end
53
+
54
+ it 'preserves multiline content and its frozen input' do
55
+ input = " third item \n\n \t \n[first link](path here)\n\tsecond item\t\n".freeze
56
+
57
+ result = cleaner.remove_inner_whitespaces(input)
58
+
59
+ expect(result).to eq " third item \n\n \t \n[first link](path here)\n\tsecond item\t\n"
60
+ end
53
61
  end
54
62
 
55
63
  describe '#clean_punctuation_characters' do
data/spec/spec_helper.rb CHANGED
@@ -2,9 +2,15 @@ require 'simplecov'
2
2
  require 'debug' unless RUBY_ENGINE == 'jruby'
3
3
 
4
4
  SimpleCov.profiles.define 'gem' do
5
- add_filter '/spec/'
6
- add_filter '/autotest/'
7
- add_group 'Libraries', '/lib/'
5
+ if SimpleCov.respond_to?(:skip)
6
+ skip '/spec/'
7
+ skip '/autotest/'
8
+ group 'Libraries', '/lib/'
9
+ else
10
+ add_filter '/spec/'
11
+ add_filter '/autotest/'
12
+ add_group 'Libraries', '/lib/'
13
+ end
8
14
  end
9
15
  SimpleCov.start 'gem'
10
16
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reverse_markdown
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.2
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Opper
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-01-20 00:00:00.000000000 Z
11
+ date: 2026-11-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: nokogiri
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: ostruct
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
83
97
  - !ruby/object:Gem::Dependency
84
98
  name: debug
85
99
  requirement: !ruby/object:Gem::Requirement
@@ -146,6 +160,7 @@ files:
146
160
  - lib/reverse_markdown/converters/text.rb
147
161
  - lib/reverse_markdown/converters/tr.rb
148
162
  - lib/reverse_markdown/errors.rb
163
+ - lib/reverse_markdown/literal_content.rb
149
164
  - lib/reverse_markdown/version.rb
150
165
  - reverse_markdown.gemspec
151
166
  - spec/assets/anchors.html
@@ -170,6 +185,7 @@ files:
170
185
  - spec/components/html_fragment_spec.rb
171
186
  - spec/components/iframe_spec.rb
172
187
  - spec/components/lists_spec.rb
188
+ - spec/components/literal_code_spec.rb
173
189
  - spec/components/paragraphs_spec.rb
174
190
  - spec/components/quotation_spec.rb
175
191
  - spec/components/tables_spec.rb
@@ -237,6 +253,7 @@ test_files:
237
253
  - spec/components/html_fragment_spec.rb
238
254
  - spec/components/iframe_spec.rb
239
255
  - spec/components/lists_spec.rb
256
+ - spec/components/literal_code_spec.rb
240
257
  - spec/components/paragraphs_spec.rb
241
258
  - spec/components/quotation_spec.rb
242
259
  - spec/components/tables_spec.rb