html-to-markdown 2.29.0-arm64-darwin
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 +7 -0
- data/.bundle/config +2 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +29 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +223 -0
- data/README.md +345 -0
- data/Rakefile +32 -0
- data/Steepfile +26 -0
- data/bin/benchmark.rb +232 -0
- data/exe/html-to-markdown +6 -0
- data/html-to-markdown-rb.gemspec +99 -0
- data/lib/html_to_markdown/cli.rb +21 -0
- data/lib/html_to_markdown/cli_proxy.rb +74 -0
- data/lib/html_to_markdown/version.rb +5 -0
- data/lib/html_to_markdown.rb +211 -0
- data/lib/html_to_markdown_rb.bundle +0 -0
- data/sig/html_to_markdown/cli.rbs +24 -0
- data/sig/html_to_markdown/cli_proxy.rbs +48 -0
- data/sig/html_to_markdown.rbs +498 -0
- data/sig/open3.rbs +12 -0
- data/spec/cli_proxy_spec.rb +42 -0
- data/spec/convert_spec.rb +77 -0
- data/spec/convert_with_tables_spec.rb +194 -0
- data/spec/metadata_extraction_spec.rb +437 -0
- data/spec/spec_helper.rb +10 -0
- data/spec/visitor_issue_187_spec.rb +605 -0
- data/spec/visitor_spec.rb +1149 -0
- metadata +80 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe HtmlToMarkdown do
|
|
6
|
+
describe '.convert_with_tables' do
|
|
7
|
+
it 'returns a hash with content, metadata, and tables keys' do
|
|
8
|
+
html = '<table><tr><td>Cell</td></tr></table>'
|
|
9
|
+
result = described_class.convert_with_tables(html)
|
|
10
|
+
|
|
11
|
+
expect(result).to be_a(Hash)
|
|
12
|
+
expect(result).to include(:content, :metadata, :tables)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
context 'with a basic table with header' do
|
|
16
|
+
let(:html) do
|
|
17
|
+
<<~HTML
|
|
18
|
+
<table>
|
|
19
|
+
<thead>
|
|
20
|
+
<tr><th>Name</th><th>Age</th></tr>
|
|
21
|
+
</thead>
|
|
22
|
+
<tbody>
|
|
23
|
+
<tr><td>Alice</td><td>30</td></tr>
|
|
24
|
+
</tbody>
|
|
25
|
+
</table>
|
|
26
|
+
HTML
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'extracts exactly one table' do
|
|
30
|
+
result = described_class.convert_with_tables(html)
|
|
31
|
+
|
|
32
|
+
expect(result[:tables].length).to eq(1)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'extracts cells as rows of columns' do
|
|
36
|
+
result = described_class.convert_with_tables(html)
|
|
37
|
+
table = result[:tables][0]
|
|
38
|
+
|
|
39
|
+
expect(table[:cells]).to be_an(Array)
|
|
40
|
+
expect(table[:cells].length).to eq(2)
|
|
41
|
+
expect(table[:cells][0]).to eq(%w[Name Age])
|
|
42
|
+
expect(table[:cells][1]).to eq(%w[Alice 30])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'provides markdown representation' do
|
|
46
|
+
result = described_class.convert_with_tables(html)
|
|
47
|
+
table = result[:tables][0]
|
|
48
|
+
|
|
49
|
+
expect(table[:markdown]).to be_a(String)
|
|
50
|
+
expect(table[:markdown]).to include('Name')
|
|
51
|
+
expect(table[:markdown]).to include('Alice')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'marks header rows correctly' do
|
|
55
|
+
result = described_class.convert_with_tables(html)
|
|
56
|
+
table = result[:tables][0]
|
|
57
|
+
|
|
58
|
+
expect(table[:is_header_row]).to be_an(Array)
|
|
59
|
+
expect(table[:is_header_row].length).to eq(2)
|
|
60
|
+
expect(table[:is_header_row][0]).to be true
|
|
61
|
+
expect(table[:is_header_row][1]).to be false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'includes converted markdown content' do
|
|
65
|
+
result = described_class.convert_with_tables(html)
|
|
66
|
+
|
|
67
|
+
expect(result[:content]).to be_a(String)
|
|
68
|
+
expect(result[:content]).not_to be_empty
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context 'with empty HTML' do
|
|
73
|
+
it 'returns empty tables array' do
|
|
74
|
+
result = described_class.convert_with_tables('')
|
|
75
|
+
|
|
76
|
+
expect(result[:tables]).to eq([])
|
|
77
|
+
expect(result[:content]).to be_a(String)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
context 'with HTML containing no tables' do
|
|
82
|
+
it 'returns empty tables array' do
|
|
83
|
+
html = '<p>No tables here</p>'
|
|
84
|
+
result = described_class.convert_with_tables(html)
|
|
85
|
+
|
|
86
|
+
expect(result[:tables]).to eq([])
|
|
87
|
+
expect(result[:content]).to include('No tables here')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
context 'with multiple tables' do
|
|
92
|
+
let(:html) do
|
|
93
|
+
<<~HTML
|
|
94
|
+
<table>
|
|
95
|
+
<tr><th>A</th></tr>
|
|
96
|
+
<tr><td>1</td></tr>
|
|
97
|
+
</table>
|
|
98
|
+
<p>Some text between tables</p>
|
|
99
|
+
<table>
|
|
100
|
+
<tr><th>B</th><th>C</th></tr>
|
|
101
|
+
<tr><td>2</td><td>3</td></tr>
|
|
102
|
+
<tr><td>4</td><td>5</td></tr>
|
|
103
|
+
</table>
|
|
104
|
+
HTML
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'extracts all tables' do
|
|
108
|
+
result = described_class.convert_with_tables(html)
|
|
109
|
+
|
|
110
|
+
expect(result[:tables].length).to eq(2)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'preserves table order' do
|
|
114
|
+
result = described_class.convert_with_tables(html)
|
|
115
|
+
|
|
116
|
+
first_table = result[:tables][0]
|
|
117
|
+
second_table = result[:tables][1]
|
|
118
|
+
|
|
119
|
+
expect(first_table[:cells][0]).to eq(['A'])
|
|
120
|
+
expect(second_table[:cells][0]).to eq(%w[B C])
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'extracts correct row counts per table' do
|
|
124
|
+
result = described_class.convert_with_tables(html)
|
|
125
|
+
|
|
126
|
+
expect(result[:tables][0][:cells].length).to eq(2)
|
|
127
|
+
expect(result[:tables][1][:cells].length).to eq(3)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
context 'with special characters in cells' do
|
|
132
|
+
let(:html) do
|
|
133
|
+
<<~HTML
|
|
134
|
+
<table>
|
|
135
|
+
<tr><th>Key</th><th>Value</th></tr>
|
|
136
|
+
<tr><td>Brackets <></td><td>Ampersand &</td></tr>
|
|
137
|
+
<tr><td>Quotes "double"</td><td>Quotes 'single'</td></tr>
|
|
138
|
+
<tr><td>Unicode: cafe\u0301</td><td>Emoji: test</td></tr>
|
|
139
|
+
</table>
|
|
140
|
+
HTML
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'handles HTML entities in cells' do
|
|
144
|
+
result = described_class.convert_with_tables(html)
|
|
145
|
+
table = result[:tables][0]
|
|
146
|
+
|
|
147
|
+
expect(table[:cells][1][0]).to include('<>')
|
|
148
|
+
expect(table[:cells][1][1]).to include('&')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'handles quotes in cells' do
|
|
152
|
+
result = described_class.convert_with_tables(html)
|
|
153
|
+
table = result[:tables][0]
|
|
154
|
+
|
|
155
|
+
expect(table[:cells][2][0]).to include('"double"')
|
|
156
|
+
expect(table[:cells][2][1]).to include("'single'")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'handles unicode in cells' do
|
|
160
|
+
result = described_class.convert_with_tables(html)
|
|
161
|
+
table = result[:tables][0]
|
|
162
|
+
|
|
163
|
+
expect(table[:cells][3][0]).to be_a(String)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
context 'with conversion options' do
|
|
168
|
+
it 'accepts options hash' do
|
|
169
|
+
html = '<table><tr><th>Header</th></tr><tr><td>Data</td></tr></table>'
|
|
170
|
+
result = described_class.convert_with_tables(html, { heading_style: :atx })
|
|
171
|
+
|
|
172
|
+
expect(result).to be_a(Hash)
|
|
173
|
+
expect(result[:tables].length).to eq(1)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'accepts nil options' do
|
|
177
|
+
html = '<table><tr><td>Data</td></tr></table>'
|
|
178
|
+
result = described_class.convert_with_tables(html, nil, nil)
|
|
179
|
+
|
|
180
|
+
expect(result).to be_a(Hash)
|
|
181
|
+
expect(result[:tables].length).to eq(1)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
context 'with metadata config' do
|
|
186
|
+
it 'includes metadata when configured' do
|
|
187
|
+
html = '<html><head><title>Test</title></head><body><table><tr><td>Data</td></tr></table></body></html>'
|
|
188
|
+
result = described_class.convert_with_tables(html, nil, { extract_headers: true })
|
|
189
|
+
|
|
190
|
+
expect(result[:metadata]).to be_a(Hash).or(be_nil)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe HtmlToMarkdown do
|
|
6
|
+
describe '.convert_with_metadata' do
|
|
7
|
+
it 'returns array with markdown and metadata' do
|
|
8
|
+
html = '<html><head><title>Test</title></head><body><p>Content</p></body></html>'
|
|
9
|
+
result = described_class.convert_with_metadata(html)
|
|
10
|
+
|
|
11
|
+
expect(result).to be_an(Array)
|
|
12
|
+
expect(result.length).to eq(2)
|
|
13
|
+
expect(result[0]).to be_a(String)
|
|
14
|
+
expect(result[1]).to be_a(Hash)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
context 'when extracting document metadata' do
|
|
18
|
+
it 'extracts title' do
|
|
19
|
+
html = '<html><head><title>My Page Title</title></head><body><p>Content</p></body></html>'
|
|
20
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
21
|
+
|
|
22
|
+
expect(metadata[:document][:title]).to eq('My Page Title')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'extracts description' do
|
|
26
|
+
html = <<~HTML
|
|
27
|
+
<html>
|
|
28
|
+
<head><meta name="description" content="Page description"></head>
|
|
29
|
+
<body><p>Content</p></body>
|
|
30
|
+
</html>
|
|
31
|
+
HTML
|
|
32
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
33
|
+
|
|
34
|
+
expect(metadata[:document][:description]).to eq('Page description')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'extracts keywords' do
|
|
38
|
+
html = <<~HTML
|
|
39
|
+
<html>
|
|
40
|
+
<head><meta name="keywords" content="keyword1, keyword2, keyword3"></head>
|
|
41
|
+
<body><p>Content</p></body>
|
|
42
|
+
</html>
|
|
43
|
+
HTML
|
|
44
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
45
|
+
|
|
46
|
+
expect(metadata[:document][:keywords]).to include('keyword1', 'keyword2', 'keyword3')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'extracts author' do
|
|
50
|
+
html = '<html><head><meta name="author" content="John Doe"></head><body><p>Content</p></body></html>'
|
|
51
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
52
|
+
|
|
53
|
+
expect(metadata[:document][:author]).to eq('John Doe')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'extracts base href' do
|
|
57
|
+
html = '<html><head><base href="https://example.com/"></head><body><p>Content</p></body></html>'
|
|
58
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
59
|
+
|
|
60
|
+
expect(metadata[:document][:base_href]).to eq('https://example.com/')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'extracts canonical URL' do
|
|
64
|
+
html = '<html><head><link rel="canonical" href="https://example.com/page"></head><body><p>Content</p></body></html>'
|
|
65
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
66
|
+
|
|
67
|
+
expect(metadata[:document][:canonical_url]).to eq('https://example.com/page')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'extracts language' do
|
|
71
|
+
html = '<html lang="en"><head></head><body><p>Content</p></body></html>'
|
|
72
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
73
|
+
|
|
74
|
+
expect(metadata[:document][:language]).to eq('en')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'extracts text direction' do
|
|
78
|
+
html = '<html dir="ltr"><head></head><body><p>Content</p></body></html>'
|
|
79
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
80
|
+
|
|
81
|
+
expect(metadata[:document][:text_direction]).to eq('ltr')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'extracts open graph metadata' do
|
|
85
|
+
html = <<~HTML
|
|
86
|
+
<html>
|
|
87
|
+
<head>
|
|
88
|
+
<meta property="og:title" content="OG Title">
|
|
89
|
+
<meta property="og:description" content="OG Description">
|
|
90
|
+
<meta property="og:image" content="https://example.com/image.jpg">
|
|
91
|
+
</head>
|
|
92
|
+
<body><p>Content</p></body>
|
|
93
|
+
</html>
|
|
94
|
+
HTML
|
|
95
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
96
|
+
|
|
97
|
+
expect(metadata[:document][:open_graph]).to include(
|
|
98
|
+
'title' => 'OG Title',
|
|
99
|
+
'description' => 'OG Description',
|
|
100
|
+
'image' => 'https://example.com/image.jpg'
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'extracts twitter card metadata' do
|
|
105
|
+
html = <<~HTML
|
|
106
|
+
<html>
|
|
107
|
+
<head>
|
|
108
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
109
|
+
<meta name="twitter:title" content="Twitter Title">
|
|
110
|
+
</head>
|
|
111
|
+
<body><p>Content</p></body>
|
|
112
|
+
</html>
|
|
113
|
+
HTML
|
|
114
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
115
|
+
|
|
116
|
+
expect(metadata[:document][:twitter_card]).to include(
|
|
117
|
+
'card' => 'summary_large_image',
|
|
118
|
+
'title' => 'Twitter Title'
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'returns empty arrays and hashes for missing metadata' do
|
|
123
|
+
html = '<p>Content</p>'
|
|
124
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
125
|
+
|
|
126
|
+
expect(metadata[:document][:title]).to be_nil
|
|
127
|
+
expect(metadata[:document][:description]).to be_nil
|
|
128
|
+
expect(metadata[:document][:keywords]).to eq([])
|
|
129
|
+
expect(metadata[:document][:open_graph]).to eq({})
|
|
130
|
+
expect(metadata[:document][:twitter_card]).to eq({})
|
|
131
|
+
expect(metadata[:document][:meta_tags]).to eq({})
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
context 'when extracting header metadata' do
|
|
136
|
+
it 'extracts headers with hierarchy' do
|
|
137
|
+
html = <<~HTML
|
|
138
|
+
<html>
|
|
139
|
+
<body>
|
|
140
|
+
<h1>Main Title</h1>
|
|
141
|
+
<h2>Section</h2>
|
|
142
|
+
<h3>Subsection</h3>
|
|
143
|
+
</body>
|
|
144
|
+
</html>
|
|
145
|
+
HTML
|
|
146
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
147
|
+
|
|
148
|
+
expect(metadata[:headers].length).to eq(3)
|
|
149
|
+
expect(metadata[:headers][0][:level]).to eq(1)
|
|
150
|
+
expect(metadata[:headers][0][:text]).to eq('Main Title')
|
|
151
|
+
expect(metadata[:headers][1][:level]).to eq(2)
|
|
152
|
+
expect(metadata[:headers][1][:text]).to eq('Section')
|
|
153
|
+
expect(metadata[:headers][2][:level]).to eq(3)
|
|
154
|
+
expect(metadata[:headers][2][:text]).to eq('Subsection')
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'includes header id' do
|
|
158
|
+
html = '<html><body><h1 id="main-title">Title</h1></body></html>'
|
|
159
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
160
|
+
|
|
161
|
+
expect(metadata[:headers][0][:id]).to eq('main-title')
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'includes depth and html_offset' do
|
|
165
|
+
html = '<html><body><h1>Title</h1></body></html>'
|
|
166
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
167
|
+
|
|
168
|
+
header = metadata[:headers][0]
|
|
169
|
+
expect(header).to include(:depth, :html_offset)
|
|
170
|
+
expect(header[:depth]).to be_a(Integer)
|
|
171
|
+
expect(header[:html_offset]).to be_a(Integer)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
context 'when extracting link metadata' do
|
|
176
|
+
it 'extracts links with classification' do
|
|
177
|
+
html = <<~HTML
|
|
178
|
+
<html>
|
|
179
|
+
<body>
|
|
180
|
+
<a href="#section">Anchor</a>
|
|
181
|
+
<a href="https://example.com">External</a>
|
|
182
|
+
<a href="/page">Internal</a>
|
|
183
|
+
<a href="mailto:test@example.com">Email</a>
|
|
184
|
+
<a href="tel:+1234567890">Phone</a>
|
|
185
|
+
</body>
|
|
186
|
+
</html>
|
|
187
|
+
HTML
|
|
188
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
189
|
+
|
|
190
|
+
links = metadata[:links]
|
|
191
|
+
expect(links.length).to eq(5)
|
|
192
|
+
|
|
193
|
+
expect(links[0][:link_type]).to eq('anchor')
|
|
194
|
+
expect(links[1][:link_type]).to eq('external')
|
|
195
|
+
expect(links[2][:link_type]).to eq('internal')
|
|
196
|
+
expect(links[3][:link_type]).to eq('email')
|
|
197
|
+
expect(links[4][:link_type]).to eq('phone')
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'includes link text and href' do
|
|
201
|
+
html = '<html><body><a href="https://example.com">Click here</a></body></html>'
|
|
202
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
203
|
+
|
|
204
|
+
link = metadata[:links][0]
|
|
205
|
+
expect(link[:href]).to eq('https://example.com')
|
|
206
|
+
expect(link[:text]).to eq('Click here')
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'includes link title attribute' do
|
|
210
|
+
html = '<html><body><a href="https://example.com" title="Example Site">Link</a></body></html>'
|
|
211
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
212
|
+
|
|
213
|
+
link = metadata[:links][0]
|
|
214
|
+
expect(link[:title]).to eq('Example Site')
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'includes link rel attributes' do
|
|
218
|
+
html = '<html><body><a href="https://example.com" rel="nofollow external">Link</a></body></html>'
|
|
219
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
220
|
+
|
|
221
|
+
link = metadata[:links][0]
|
|
222
|
+
expect(link[:rel]).to include('nofollow', 'external')
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it 'includes link attributes' do
|
|
226
|
+
html = '<html><body><a href="https://example.com" data-custom="value">Link</a></body></html>'
|
|
227
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
228
|
+
|
|
229
|
+
link = metadata[:links][0]
|
|
230
|
+
expect(link[:attributes]).to include('data-custom' => 'value')
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
context 'when extracting image metadata' do
|
|
235
|
+
it 'extracts images with source type' do
|
|
236
|
+
html = <<~HTML
|
|
237
|
+
<html>
|
|
238
|
+
<body>
|
|
239
|
+
<img src="https://example.com/image.jpg" alt="External">
|
|
240
|
+
<img src="/images/local.jpg" alt="Relative">
|
|
241
|
+
<img src="data:image/png;base64,..." alt="Data URI">
|
|
242
|
+
</body>
|
|
243
|
+
</html>
|
|
244
|
+
HTML
|
|
245
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
246
|
+
|
|
247
|
+
images = metadata[:images]
|
|
248
|
+
expect(images.length).to eq(3)
|
|
249
|
+
|
|
250
|
+
expect(images[0][:image_type]).to eq('external')
|
|
251
|
+
expect(images[1][:image_type]).to eq('relative')
|
|
252
|
+
expect(images[2][:image_type]).to eq('data_uri')
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
it 'includes image alt and title' do
|
|
256
|
+
html = '<html><body><img src="image.jpg" alt="Alt text" title="Image title"></body></html>'
|
|
257
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
258
|
+
|
|
259
|
+
image = metadata[:images][0]
|
|
260
|
+
expect(image[:alt]).to eq('Alt text')
|
|
261
|
+
expect(image[:title]).to eq('Image title')
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it 'includes image dimensions' do
|
|
265
|
+
html = '<html><body><img src="image.jpg" width="800" height="600"></body></html>'
|
|
266
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
267
|
+
|
|
268
|
+
image = metadata[:images][0]
|
|
269
|
+
expect(image[:dimensions]).to be_an(Array)
|
|
270
|
+
expect(image[:dimensions].length).to eq(2)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
it 'handles missing image attributes' do
|
|
274
|
+
html = '<html><body><img src="image.jpg"></body></html>'
|
|
275
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
276
|
+
|
|
277
|
+
image = metadata[:images][0]
|
|
278
|
+
expect(image[:alt]).to be_nil
|
|
279
|
+
expect(image[:title]).to be_nil
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
context 'with metadata configuration flags' do
|
|
284
|
+
it 'respects extract_headers flag' do
|
|
285
|
+
html = '<html><body><h1>Title</h1><p>Content</p></body></html>'
|
|
286
|
+
config = { extract_headers: false }
|
|
287
|
+
_, metadata = described_class.convert_with_metadata(html, nil, config)
|
|
288
|
+
|
|
289
|
+
expect(metadata[:headers]).to eq([])
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
it 'respects extract_links flag' do
|
|
293
|
+
html = '<html><body><a href="https://example.com">Link</a></body></html>'
|
|
294
|
+
config = { extract_links: false }
|
|
295
|
+
_, metadata = described_class.convert_with_metadata(html, nil, config)
|
|
296
|
+
|
|
297
|
+
expect(metadata[:links]).to eq([])
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
it 'respects extract_images flag' do
|
|
301
|
+
html = '<html><body><img src="image.jpg" alt="test"></body></html>'
|
|
302
|
+
config = { extract_images: false }
|
|
303
|
+
_, metadata = described_class.convert_with_metadata(html, nil, config)
|
|
304
|
+
|
|
305
|
+
expect(metadata[:images]).to eq([])
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it 'respects extract_structured_data flag' do
|
|
309
|
+
html = '<html><body><script type="application/ld+json">{"@type":"Article"}</script></body></html>'
|
|
310
|
+
config = { extract_structured_data: false }
|
|
311
|
+
_, metadata = described_class.convert_with_metadata(html, nil, config)
|
|
312
|
+
|
|
313
|
+
expect(metadata[:structured_data]).to eq([])
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
context 'with conversion options and metadata config' do
|
|
318
|
+
it 'accepts both conversion options and metadata config' do
|
|
319
|
+
html = '<html><head><title>Test</title></head><body><h1>Heading</h1></body></html>'
|
|
320
|
+
conv_opts = { heading_style: :atx_closed }
|
|
321
|
+
meta_opts = { extract_headers: true }
|
|
322
|
+
|
|
323
|
+
markdown, metadata = described_class.convert_with_metadata(html, conv_opts, meta_opts)
|
|
324
|
+
|
|
325
|
+
expect(markdown).to include('# Heading #')
|
|
326
|
+
expect(metadata[:headers].length).to eq(1)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
it 'works with nil options' do
|
|
330
|
+
html = '<html><head><title>Test</title></head><body><p>Content</p></body></html>'
|
|
331
|
+
result = described_class.convert_with_metadata(html, nil, nil)
|
|
332
|
+
|
|
333
|
+
expect(result).to be_an(Array)
|
|
334
|
+
expect(result.length).to eq(2)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
context 'when extracting structured data' do
|
|
339
|
+
it 'extracts JSON-LD blocks' do
|
|
340
|
+
html = <<~HTML
|
|
341
|
+
<html>
|
|
342
|
+
<head>
|
|
343
|
+
<script type="application/ld+json">
|
|
344
|
+
{"@context":"https://schema.org","@type":"Article","headline":"Test"}
|
|
345
|
+
</script>
|
|
346
|
+
</head>
|
|
347
|
+
<body><p>Content</p></body>
|
|
348
|
+
</html>
|
|
349
|
+
HTML
|
|
350
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
351
|
+
|
|
352
|
+
expect(metadata[:structured_data]).to be_an(Array)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
context 'with edge cases' do
|
|
357
|
+
it 'handles empty HTML' do
|
|
358
|
+
html = ''
|
|
359
|
+
markdown, metadata = described_class.convert_with_metadata(html)
|
|
360
|
+
|
|
361
|
+
expect(markdown).to be_a(String)
|
|
362
|
+
expect(metadata).to be_a(Hash)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
it 'handles malformed HTML' do
|
|
366
|
+
html = '<html><head><title>Unclosed'
|
|
367
|
+
markdown, metadata = described_class.convert_with_metadata(html)
|
|
368
|
+
|
|
369
|
+
expect(markdown).to be_a(String)
|
|
370
|
+
expect(metadata).to be_a(Hash)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
it 'handles special characters in metadata' do
|
|
374
|
+
html = '<html><head><title>Title with "quotes" & <brackets></title></head><body><p>Content</p></body></html>'
|
|
375
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
376
|
+
|
|
377
|
+
expect(metadata[:document][:title]).to be_a(String)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
it 'handles whitespace in metadata' do
|
|
381
|
+
html = '<html><head><title> Title with spaces </title></head><body><p>Content</p></body></html>'
|
|
382
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
383
|
+
|
|
384
|
+
expect(metadata[:document][:title]).to match(/Title.*spaces/)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
it 'handles multiple values for same metadata key' do
|
|
388
|
+
html = <<~HTML
|
|
389
|
+
<html>
|
|
390
|
+
<head>
|
|
391
|
+
<meta name="author" content="Author 1">
|
|
392
|
+
<meta name="author" content="Author 2">
|
|
393
|
+
</head>
|
|
394
|
+
<body><p>Content</p></body>
|
|
395
|
+
</html>
|
|
396
|
+
HTML
|
|
397
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
398
|
+
|
|
399
|
+
expect(metadata[:document][:author]).to be_a(String)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
context 'when returning value structure' do
|
|
404
|
+
it 'returns proper metadata hash structure' do
|
|
405
|
+
html = <<~HTML
|
|
406
|
+
<html>
|
|
407
|
+
<head><title>Test</title><base href="https://example.com"></head>
|
|
408
|
+
<body><h1>H1</h1><a href="link">Link</a><img src="img.jpg"></body>
|
|
409
|
+
</html>
|
|
410
|
+
HTML
|
|
411
|
+
_, metadata = described_class.convert_with_metadata(html)
|
|
412
|
+
|
|
413
|
+
expect(metadata).to include(
|
|
414
|
+
:document,
|
|
415
|
+
:headers,
|
|
416
|
+
:links,
|
|
417
|
+
:images,
|
|
418
|
+
:structured_data
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
expect(metadata[:document]).to include(
|
|
422
|
+
:title,
|
|
423
|
+
:description,
|
|
424
|
+
:keywords,
|
|
425
|
+
:author,
|
|
426
|
+
:canonical_url,
|
|
427
|
+
:base_href,
|
|
428
|
+
:language,
|
|
429
|
+
:text_direction,
|
|
430
|
+
:open_graph,
|
|
431
|
+
:twitter_card,
|
|
432
|
+
:meta_tags
|
|
433
|
+
)
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|