blueprint-html2slim 1.0.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.
@@ -0,0 +1,338 @@
1
+ require 'nokogiri'
2
+ require 'erubi'
3
+ require 'strscan'
4
+
5
+ module Blueprint
6
+ module Html2Slim
7
+ class Converter
8
+ VOID_ELEMENTS = %w[area base br col embed hr img input link meta param source track wbr].freeze
9
+ INLINE_ELEMENTS = %w[a abbr b bdo br cite code dfn em i kbd mark q s samp small span strong sub sup time u
10
+ var].freeze
11
+
12
+ def initialize(options = {})
13
+ @options = options
14
+ @indent_size = options[:indent_size] || 2
15
+ @erb_pattern = /<%(-|=)?(.+?)(-)?%>/m
16
+ end
17
+
18
+ def convert(html_content)
19
+ lines = []
20
+
21
+ # Handle DOCTYPE declaration
22
+ if html_content =~ /<!DOCTYPE\s+(.+?)>/i
23
+ doctype_content = ::Regexp.last_match(1)
24
+ if doctype_content =~ /strict/i
25
+ lines << "doctype strict"
26
+ elsif doctype_content =~ /transitional/i
27
+ lines << "doctype transitional"
28
+ elsif doctype_content =~ /frameset/i
29
+ lines << "doctype frameset"
30
+ elsif doctype_content =~ /html$/i
31
+ lines << "doctype html"
32
+ else
33
+ lines << "doctype"
34
+ end
35
+ # Remove DOCTYPE from content for further processing
36
+ html_content = html_content.sub(/<!DOCTYPE\s+.+?>/i, '')
37
+ end
38
+
39
+ html_content = preprocess_erb(html_content)
40
+ # Use HTML.parse for full documents, DocumentFragment for fragments
41
+ if html_content =~ /<html/i
42
+ doc = Nokogiri::HTML.parse(html_content)
43
+ # Process the html element if it exists
44
+ if html_element = doc.at('html')
45
+ node_lines = process_node(html_element, 0)
46
+ lines.concat(node_lines) unless node_lines.empty?
47
+ else
48
+ doc.root.children.each do |node|
49
+ node_lines = process_node(node, 0)
50
+ lines.concat(node_lines) unless node_lines.empty?
51
+ end
52
+ end
53
+ else
54
+ doc = Nokogiri::HTML::DocumentFragment.parse(html_content)
55
+ doc.children.each do |node|
56
+ node_lines = process_node(node, 0)
57
+ lines.concat(node_lines) unless node_lines.empty?
58
+ end
59
+ end
60
+ result = lines.join("\n")
61
+ # Ensure the result ends with a newline
62
+ result += "\n" unless result.end_with?("\n")
63
+ result
64
+ end
65
+
66
+ private
67
+
68
+ def preprocess_erb(content)
69
+ # Convert ERB blocks to span elements to preserve hierarchy
70
+ # This approach is inspired by the original html2slim gem
71
+
72
+ # Keep ERB tags in attributes unchanged by temporarily replacing them
73
+ erb_in_attrs = []
74
+ content = content.gsub(/(<[^>]*)(<%=?.+?%>)([^>]*>)/) do
75
+ before = ::Regexp.last_match(1)
76
+ erb = ::Regexp.last_match(2)
77
+ after = ::Regexp.last_match(3)
78
+ placeholder = "ERB_IN_ATTR_#{erb_in_attrs.length}"
79
+ erb_in_attrs << erb
80
+ "#{before}#{placeholder}#{after}"
81
+ end
82
+
83
+ # Handle multi-line ERB blocks first (with m flag for multiline)
84
+ content = content.gsub(/<%\s*\n(.*?)\n\s*-?%>/m) do
85
+ code_lines = ::Regexp.last_match(1).strip.split("\n")
86
+ # Convert to multiple single-line ERB comments
87
+ code_lines.map { |line| "<!--ERB_CODE:#{line.strip}-->" }.join("\n")
88
+ end
89
+
90
+ # Convert simple ERB output tags that don't create blocks
91
+ # This prevents them from being caught by the block regex
92
+ content = content.gsub(/<%=\s*([^%]+?)\s*%>/) do
93
+ code = ::Regexp.last_match(1).strip
94
+ # Skip if it's a do block
95
+ if code =~ /\bdo\s*(\|[^|]*\|)?\s*$/
96
+ "<%= #{code} %>" # Keep original, will be processed later
97
+ else
98
+ %(<!--ERB_OUTPUT:#{code}-->)
99
+ end
100
+ end
101
+
102
+ # Convert ERB blocks that create structure (do...end, if...end, etc.)
103
+ # to span elements so their content becomes proper children
104
+ content = content.gsub(/<%(-|=)?\s*((\s*(case|if|for|unless|until|while) .+?)|.+?do\s*(\|[^|]*\|)?\s*)-?%>/) do
105
+ type = ::Regexp.last_match(1)
106
+ code = ::Regexp.last_match(2).strip
107
+ # Preserve whether it was = or - in the code attribute
108
+ prefix = type == '=' ? '=' : ''
109
+ %(<span erb-code="#{prefix}#{code.gsub('"', '&quot;')}">)
110
+ end
111
+
112
+ # Handle else
113
+ content = content.gsub(/<%-?\s*else\s*-?%>/, %(</span><span erb-code="else">))
114
+
115
+ # Handle elsif
116
+ content = content.gsub(/<%-?\s*(elsif .+?)\s*-?%>/) do
117
+ code = ::Regexp.last_match(1).strip
118
+ %(</span><span erb-code="#{code.gsub('"', '&quot;')}">)
119
+ end
120
+
121
+ # Handle when
122
+ content = content.gsub(/<%-?\s*(when .+?)\s*-?%>/) do
123
+ code = ::Regexp.last_match(1).strip
124
+ %(</span><span erb-code="#{code.gsub('"', '&quot;')}">)
125
+ end
126
+
127
+ # Handle end statements - close the span
128
+ content = content.gsub(/<%\s*(end|}|end\s+-)\s*%>/, %(</span>))
129
+
130
+ # Convert any remaining ERB code tags to comments
131
+ content = content.gsub(/<%-?\s*(.+?)\s*%>/) do
132
+ code = ::Regexp.last_match(1).strip
133
+ %(<!--ERB_CODE:#{code}-->)
134
+ end
135
+
136
+ # Restore ERB tags in attributes
137
+ erb_in_attrs.each_with_index do |erb, i|
138
+ content = content.gsub("ERB_IN_ATTR_#{i}", erb)
139
+ end
140
+
141
+ content
142
+ end
143
+
144
+ def process_node(node, depth)
145
+ case node
146
+ when Nokogiri::XML::Element
147
+ process_element(node, depth)
148
+ when Nokogiri::XML::Text
149
+ process_text(node, depth)
150
+ when Nokogiri::XML::Comment
151
+ process_comment(node, depth)
152
+ else
153
+ []
154
+ end
155
+ end
156
+
157
+ def process_element(node, depth)
158
+ lines = []
159
+ indent = ' ' * (depth * @indent_size)
160
+
161
+ # Check if this is an ERB span element
162
+ if node.name == 'span' && node['erb-code']
163
+ erb_code = node['erb-code'].gsub('&quot;', '"')
164
+
165
+ # Determine if it's output (=) or code (-)
166
+ if erb_code =~ /^(if|unless|case|for|while|elsif|else|when)\b/
167
+ lines << "#{indent}- #{erb_code}"
168
+ elsif erb_code.start_with?('=')
169
+ # It was originally <%= ... %>, use = prefix
170
+ lines << "#{indent}= #{erb_code[1..-1].strip}"
171
+ else
172
+ # It was originally <% ... %>, use - prefix
173
+ lines << "#{indent}- #{erb_code}"
174
+ end
175
+
176
+ # Process children with increased depth
177
+ node.children.each do |child|
178
+ child_lines = process_node(child, depth + 1)
179
+ lines.concat(child_lines) unless child_lines.empty?
180
+ end
181
+
182
+ return lines
183
+ end
184
+
185
+ tag_line = build_tag_line(node, depth)
186
+
187
+ if VOID_ELEMENTS.include?(node.name.downcase)
188
+ lines << "#{indent}#{tag_line}"
189
+ elsif node.children.empty?
190
+ lines << "#{indent}#{tag_line}"
191
+ elsif single_text_child?(node)
192
+ text = node.children.first.text
193
+ if text.strip.empty?
194
+ lines << "#{indent}#{tag_line}"
195
+ elsif node.name.downcase == 'pre'
196
+ # Preserve whitespace in pre tags but still strip leading/trailing
197
+ text = text.strip.gsub(/\n\s*/, '\n ')
198
+ lines << "#{indent}#{tag_line} #{text}"
199
+ elsif text.include?("\n") && text.strip.lines.count > 1
200
+ # Multiline text - use pipe notation
201
+ lines << "#{indent}#{tag_line}"
202
+ text.strip.lines.each do |line|
203
+ lines << "#{" " * ((depth + 1) * @indent_size)}| #{line.strip}" unless line.strip.empty?
204
+ end
205
+ else
206
+ text = process_inline_text(text.strip)
207
+ if text.empty?
208
+ lines << "#{indent}#{tag_line}"
209
+ else
210
+ lines << "#{indent}#{tag_line} #{text}"
211
+ end
212
+ end
213
+ else
214
+ lines << "#{indent}#{tag_line}"
215
+ node.children.each do |child|
216
+ child_lines = process_node(child, depth + 1)
217
+ lines.concat(child_lines) unless child_lines.empty?
218
+ end
219
+ end
220
+
221
+ lines
222
+ end
223
+
224
+ def build_tag_line(node, _depth)
225
+ tag = node.name
226
+ id = node['id']
227
+ # Strip and split classes, filtering out empty strings
228
+ classes = node['class']&.strip&.split(/\s+/)&.reject(&:empty?) || []
229
+ attributes = collect_attributes(node)
230
+
231
+ # Treat empty id as no id
232
+ id = nil if id && id.strip.empty?
233
+
234
+ line = if tag.downcase == 'div' && (id || !classes.empty?)
235
+ ''
236
+ else
237
+ tag
238
+ end
239
+
240
+ line += "##{id}" if id
241
+
242
+ classes.each do |cls|
243
+ line += ".#{cls}"
244
+ end
245
+
246
+ unless attributes.empty?
247
+ line = 'div' if line.empty?
248
+ line += build_attribute_string(attributes)
249
+ end
250
+
251
+ line = tag if line.empty?
252
+ line
253
+ end
254
+
255
+ def collect_attributes(node)
256
+ attributes = {}
257
+ node.attributes.each do |name, attr|
258
+ next if %w[id class].include?(name)
259
+
260
+ attributes[name] = attr.value
261
+ end
262
+ attributes
263
+ end
264
+
265
+ def build_attribute_string(attributes)
266
+ return '' if attributes.empty?
267
+
268
+ attr_parts = attributes.map do |key, value|
269
+ if value.nil? || value == ''
270
+ key
271
+ elsif value.include?('"')
272
+ "#{key}='#{value}'"
273
+ else
274
+ "#{key}=\"#{value}\""
275
+ end
276
+ end
277
+
278
+ "[#{attr_parts.join(" ")}]"
279
+ end
280
+
281
+ def process_text(node, depth)
282
+ text = node.text
283
+ return [] if text.strip.empty? && !text.include?("\n")
284
+
285
+ indent = ' ' * (depth * @indent_size)
286
+ processed_text = process_inline_text(text.strip)
287
+ return [] if processed_text.empty?
288
+
289
+ if processed_text.include?("\n")
290
+ processed_text.split("\n").map { |line| "#{indent}| #{line}" }
291
+ else
292
+ ["#{indent}| #{processed_text}"]
293
+ end
294
+ end
295
+
296
+ def process_comment(node, depth)
297
+ comment_text = node.text.strip
298
+
299
+ # Extract indentation level if present
300
+ extra_indent = 0
301
+ if comment_text =~ /:INDENT:(\d+)$/
302
+ extra_indent = $1.to_i
303
+ comment_text = comment_text.sub(/:INDENT:\d+$/, '')
304
+ end
305
+
306
+ total_depth = depth + extra_indent
307
+ indent = ' ' * (total_depth * @indent_size)
308
+
309
+ if comment_text.start_with?('ERB_OUTPUT_BLOCK:')
310
+ erb_content = comment_text.sub('ERB_OUTPUT_BLOCK:', '')
311
+ ["#{indent}= #{erb_content}"]
312
+ elsif comment_text.start_with?('ERB_OUTPUT:')
313
+ erb_content = comment_text.sub('ERB_OUTPUT:', '')
314
+ ["#{indent}= #{erb_content}"]
315
+ elsif comment_text.start_with?('ERB_CODE_BLOCK:')
316
+ erb_content = comment_text.sub('ERB_CODE_BLOCK:', '')
317
+ ["#{indent}- #{erb_content}"]
318
+ elsif comment_text.start_with?('ERB_CODE:')
319
+ erb_content = comment_text.sub('ERB_CODE:', '')
320
+ ["#{indent}- #{erb_content}"]
321
+ elsif comment_text == 'ERB_END'
322
+ # Don't output 'end' statements in Slim
323
+ []
324
+ else
325
+ ["#{indent}/! #{comment_text}"]
326
+ end
327
+ end
328
+
329
+ def process_inline_text(text)
330
+ text.gsub(/\s+/, ' ').strip
331
+ end
332
+
333
+ def single_text_child?(node)
334
+ node.children.size == 1 && node.children.first.text?
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,5 @@
1
+ module Blueprint
2
+ module Html2Slim
3
+ VERSION = '1.0.0'.freeze
4
+ end
5
+ end
@@ -0,0 +1,7 @@
1
+ require_relative 'html2slim/version'
2
+ require_relative 'html2slim/converter'
3
+
4
+ module Blueprint
5
+ module Html2Slim
6
+ end
7
+ end
metadata ADDED
@@ -0,0 +1,166 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: blueprint-html2slim
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Vladimir Elchinov
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: erubi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.12'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.12'
26
+ - !ruby/object:Gem::Dependency
27
+ name: nokogiri
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.16'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.16'
40
+ - !ruby/object:Gem::Dependency
41
+ name: thor
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.3'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.3'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rake
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '13.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rspec
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.12'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '3.12'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.50'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.50'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rubocop-rake
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '0.6'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '0.6'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rubocop-rspec
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - "~>"
115
+ - !ruby/object:Gem::Version
116
+ version: '2.22'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - "~>"
122
+ - !ruby/object:Gem::Version
123
+ version: '2.22'
124
+ description: A Ruby command-line tool to convert HTML and ERB files to Slim format
125
+ with smart naming conventions and backup options
126
+ email:
127
+ - info@railsblueprint.com
128
+ executables:
129
+ - html2slim
130
+ extensions: []
131
+ extra_rdoc_files: []
132
+ files:
133
+ - CHANGELOG.md
134
+ - LICENSE
135
+ - README.md
136
+ - bin/html2slim
137
+ - bin/index.html
138
+ - lib/blueprint/html2slim.rb
139
+ - lib/blueprint/html2slim/converter.rb
140
+ - lib/blueprint/html2slim/version.rb
141
+ homepage: https://github.com/railsblueprint/html2slim
142
+ licenses:
143
+ - MIT
144
+ metadata:
145
+ homepage_uri: https://github.com/railsblueprint/html2slim
146
+ source_code_uri: https://github.com/railsblueprint/html2slim
147
+ changelog_uri: https://github.com/railsblueprint/html2slim/blob/main/CHANGELOG.md
148
+ rubygems_mfa_required: 'true'
149
+ rdoc_options: []
150
+ require_paths:
151
+ - lib
152
+ required_ruby_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: 2.7.0
157
+ required_rubygems_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubygems_version: 3.6.7
164
+ specification_version: 4
165
+ summary: Convert HTML and ERB files to Slim format
166
+ test_files: []