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 +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.gitignore +2 -0
- data/CHANGELOG.md +12 -0
- data/README.md +1 -0
- data/lib/reverse_markdown/cleaner.rb +4 -2
- data/lib/reverse_markdown/converters/base.rb +7 -0
- data/lib/reverse_markdown/converters/bypass.rb +1 -0
- data/lib/reverse_markdown/converters/code.rb +1 -1
- data/lib/reverse_markdown/converters/li.rb +8 -9
- data/lib/reverse_markdown/converters/ol.rb +1 -2
- data/lib/reverse_markdown/converters/pre.rb +11 -9
- data/lib/reverse_markdown/literal_content.rb +29 -0
- data/lib/reverse_markdown/version.rb +1 -1
- data/lib/reverse_markdown.rb +5 -5
- data/reverse_markdown.gemspec +1 -0
- data/spec/components/html_fragment_spec.rb +8 -0
- data/spec/components/lists_spec.rb +52 -9
- data/spec/components/literal_code_spec.rb +175 -0
- data/spec/html_to_markdown_to_html_spec.rb +27 -0
- data/spec/lib/reverse_markdown/cleaner_spec.rb +8 -0
- data/spec/spec_helper.rb +9 -3
- metadata +19 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df79f3321ee1b7803fbb6a3b6ab689e5dd6ca149eb1398c4fb3c40f62e33342f
|
|
4
|
+
data.tar.gz: e33b4305f00468791b28d8544fd50c94bedc2c634378f819b6fc1c17a558d23e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2bdbe152941277e1bd891080277d8bb20bb165826ac44425159126448c559aceefb05aeed1569c09423fdfc8af1d8c6d68190814036713f53c627d966b6441cb
|
|
7
|
+
data.tar.gz: f94731850ae8879b8336e11ac80f4e6ac3ba93f1b793a11ab4e8be1847d0ecec0ed58e52a03d05e0961ea4d24a8b23f4857b95f23d88aadd4c7ff2edc6f13b2f
|
data/.github/workflows/ci.yml
CHANGED
data/.gitignore
CHANGED
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
|
@@ -18,11 +18,13 @@ module ReverseMarkdown
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def remove_inner_whitespaces(string)
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
@@ -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
|
-
|
|
7
|
-
content =
|
|
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
|
-
"#{
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
code = "```#{language(node)}\n#{content}\n```"
|
|
8
|
+
"\n#{preserve_literal(code, state)}\n"
|
|
8
9
|
else
|
|
9
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
when 'br'
|
|
20
|
+
if node.text? || node.cdata?
|
|
21
|
+
node.text
|
|
22
|
+
elsif node.name == 'br'
|
|
21
23
|
"\n"
|
|
22
24
|
else
|
|
23
|
-
|
|
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
|
data/lib/reverse_markdown.rb
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
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
|
|
data/reverse_markdown.gemspec
CHANGED
|
@@ -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 {
|
|
15
|
-
it { is_expected.to match /\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
|
-
|
|
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 "
|
|
49
|
-
it { is_expected.to include "
|
|
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 "
|
|
52
|
-
it { is_expected.to include "
|
|
53
|
-
it { is_expected.to include "
|
|
54
|
-
it { is_expected.to include "
|
|
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('&', '&').gsub('<', '<').gsub('>', '>')
|
|
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> <tag> &'"
|
|
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 &lt;tag&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 <tag>'\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�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> &'"
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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-
|
|
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
|