blueprint-html2slim 1.1.0 → 1.3.1

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,429 @@
1
+ require_relative 'slim_manipulator'
2
+
3
+ module Blueprint
4
+ module Html2Slim
5
+ class SlimExtractor < SlimManipulator
6
+ def extract_file(file_path)
7
+ content = read_file(file_path)
8
+ structure = parse_slim_structure(content)
9
+
10
+ # Handle different extraction modes
11
+ sections_to_remove = []
12
+ sections_to_keep = []
13
+
14
+ extracted = if options[:outline]
15
+ extract_outline(structure, options[:outline])
16
+ elsif options[:selector]
17
+ extract_by_selector(structure, options[:selector])
18
+ else
19
+ # Original keep/remove logic
20
+ sections_to_remove = normalize_selectors(options[:remove] || [])
21
+ sections_to_keep = normalize_selectors(options[:keep] || [])
22
+
23
+ # Default removals if not keeping specific sections
24
+ if sections_to_keep.empty? && sections_to_remove.empty?
25
+ sections_to_remove = %w[doctype html head nav header footer script body]
26
+ end
27
+
28
+ # Extract content
29
+ extract_content(structure, sections_to_keep, sections_to_remove)
30
+ end
31
+
32
+ # Remove wrapper if requested (not for outline mode)
33
+ extracted = remove_outer_wrapper(extracted) if options[:remove_wrapper] && !options[:outline]
34
+
35
+ # Clean up orphaned comments
36
+ extracted = clean_orphaned_comments(extracted)
37
+
38
+ # Rebuild the Slim content
39
+ new_content = rebuild_extracted_content(extracted)
40
+
41
+ # Write to output file
42
+ output_path = options[:output] || file_path.sub(/\.slim$/, '_extracted.slim')
43
+ write_file(output_path, new_content)
44
+
45
+ # Build appropriate response based on extraction mode
46
+ if options[:outline]
47
+ {
48
+ success: true,
49
+ mode: 'outline',
50
+ depth: options[:outline]
51
+ }
52
+ elsif options[:selector]
53
+ {
54
+ success: true,
55
+ mode: 'selector',
56
+ selector: options[:selector]
57
+ }
58
+ else
59
+ {
60
+ success: true,
61
+ removed: sections_to_remove,
62
+ kept: sections_to_keep.empty? ? nil : sections_to_keep
63
+ }
64
+ end
65
+ rescue StandardError => e
66
+ { success: false, error: e.message }
67
+ end
68
+
69
+ private
70
+
71
+ def normalize_selectors(selectors)
72
+ return [] unless selectors
73
+
74
+ selectors.flat_map do |selector|
75
+ selector.split(',').map(&:strip).map(&:downcase)
76
+ end
77
+ end
78
+
79
+ def extract_content(structure, keep_selectors, remove_selectors)
80
+ result = []
81
+ skip_until_indent = nil
82
+ keep_until_indent = nil
83
+
84
+ structure.each_with_index do |item, _index|
85
+ # If we're in skip mode, check if we've exited the skipped section
86
+ if skip_until_indent
87
+ next if item[:indent_level] > skip_until_indent
88
+
89
+ skip_until_indent = nil
90
+ # Continue processing this line
91
+
92
+ end
93
+
94
+ # If we're in keep mode, track when we exit
95
+ keep_until_indent = nil if keep_until_indent && item[:indent_level] <= keep_until_indent
96
+
97
+ # Determine what to do with this line
98
+ if !keep_selectors.empty?
99
+ # We have keep selectors - only keep matching sections
100
+ if should_keep_line?(item, keep_selectors)
101
+ # This line matches a keep selector
102
+ keep_until_indent = item[:indent_level]
103
+ result << item
104
+ elsif keep_until_indent && item[:indent_level] > keep_until_indent
105
+ # We're inside a kept section
106
+ result << item
107
+ end
108
+ # Otherwise, skip this line
109
+ elsif should_remove_line?(item, remove_selectors)
110
+ # No keep selectors - use remove logic
111
+ skip_until_indent = item[:indent_level]
112
+ next
113
+ # Skip this line and all its children
114
+ else
115
+ result << item
116
+ end
117
+ end
118
+
119
+ result
120
+ end
121
+
122
+ def should_remove_line?(item, selectors)
123
+ return false if selectors.empty?
124
+
125
+ line = item[:stripped]
126
+
127
+ selectors.any? do |selector|
128
+ case selector
129
+ when 'doctype'
130
+ item[:type] == :doctype
131
+ when 'script', 'style', 'link', 'meta'
132
+ line.start_with?(selector)
133
+ else
134
+ # Check for element match or class/id match
135
+ element_info = element_selector(line)
136
+ if element_info
137
+ element_info[:element] == selector ||
138
+ element_info[:selector].include?(".#{selector}") ||
139
+ element_info[:selector].include?("##{selector}")
140
+ else
141
+ false
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ def should_keep_line?(item, selectors)
148
+ return true if selectors.empty?
149
+
150
+ line = item[:stripped]
151
+
152
+ selectors.any? do |selector|
153
+ element_info = element_selector(line)
154
+ if element_info
155
+ element_info[:element] == selector ||
156
+ element_info[:selector].include?(".#{selector}") ||
157
+ element_info[:selector].include?("##{selector}")
158
+ else
159
+ false
160
+ end
161
+ end
162
+ end
163
+
164
+ def remove_outer_wrapper(structure)
165
+ return structure if structure.empty?
166
+
167
+ # Find the minimum indentation level
168
+ min_indent = structure.map { |item| item[:indent_level] }.min
169
+
170
+ # If there's only one element at the minimum level, remove it
171
+ root_elements = structure.select { |item| item[:indent_level] == min_indent }
172
+
173
+ if root_elements.size == 1 && root_elements.first[:type] == :element
174
+ # Remove the wrapper and decrease indentation of all children
175
+ structure = structure[1..-1].map do |item|
176
+ item[:indent_level] -= 1 if item[:indent_level] > min_indent
177
+ item
178
+ end
179
+ end
180
+
181
+ structure
182
+ end
183
+
184
+ def rebuild_extracted_content(structure)
185
+ return '' if structure.empty?
186
+
187
+ # Normalize indentation - find minimum and adjust
188
+ min_indent = structure.map { |item| item[:indent_level] }.min || 0
189
+
190
+ structure.map do |item|
191
+ adjusted_indent = item[:indent_level] - min_indent
192
+ indent_string(adjusted_indent) + item[:stripped]
193
+ end.join("\n")
194
+ end
195
+
196
+ def extract_outline(structure, max_depth)
197
+ result = []
198
+
199
+ structure.each do |item|
200
+ # Include items up to the specified depth
201
+ result << item if item[:indent_level] < max_depth
202
+ end
203
+
204
+ result
205
+ end
206
+
207
+ def extract_by_selector(structure, selector)
208
+ result = []
209
+ @current_structure = structure # Store for parent lookup
210
+
211
+ # Parse the CSS selector
212
+ selector_parts = parse_css_selector(selector)
213
+
214
+ # For child selectors like "body > section", find all matching sections
215
+ if selector_parts[:combinator] == :child
216
+ structure.each do |item|
217
+ if matches_selector?(item, selector_parts)
218
+ # Add this item and all its children
219
+ result << item
220
+ # Add children until we hit the same or lower indent level
221
+ item_index = structure.index(item)
222
+ next unless item_index
223
+
224
+ (item_index + 1...structure.size).each do |i|
225
+ child_item = structure[i]
226
+ break if child_item[:indent_level] <= item[:indent_level]
227
+ result << child_item
228
+ end
229
+ end
230
+ end
231
+ else
232
+ # Original single-section logic for simple selectors
233
+ in_selected_section = false
234
+ selected_indent = nil
235
+
236
+ structure.each do |item|
237
+ # Check if we're exiting a selected section
238
+ if in_selected_section && selected_indent && item[:indent_level] <= selected_indent
239
+ in_selected_section = false
240
+ selected_indent = nil
241
+ end
242
+
243
+ # Check if this item matches the selector
244
+ if !in_selected_section && matches_selector?(item, selector_parts)
245
+ in_selected_section = true
246
+ selected_indent = item[:indent_level]
247
+ result << item
248
+ elsif in_selected_section
249
+ result << item
250
+ end
251
+ end
252
+ end
253
+
254
+ result
255
+ end
256
+
257
+ def parse_css_selector(selector)
258
+ # Support CSS selectors: element, #id, .class, element.class, element#id
259
+ # Also support child combinator: parent > child
260
+ parts = {}
261
+
262
+ # Handle child combinator (e.g., "body > section")
263
+ if selector.include?(' > ')
264
+ parent_child = selector.split(' > ').map(&:strip)
265
+ if parent_child.size == 2
266
+ parts[:parent] = parse_simple_selector(parent_child[0])
267
+ parts[:child] = parse_simple_selector(parent_child[1])
268
+ parts[:combinator] = :child
269
+ return parts
270
+ end
271
+ end
272
+
273
+ # Handle simple selectors
274
+ parts.merge!(parse_simple_selector(selector))
275
+ parts
276
+ end
277
+
278
+ def parse_simple_selector(selector)
279
+ parts = {}
280
+
281
+ # Handle complex selectors like div.container#main
282
+ if selector =~ /^([a-z][a-z0-9]*)?([#.][\w\-#.]*)?$/i
283
+ parts[:element] = ::Regexp.last_match(1)
284
+ selector_part = ::Regexp.last_match(2)
285
+
286
+ if selector_part
287
+ # Extract ID
288
+ parts[:id] = ::Regexp.last_match(1) if selector_part =~ /#([\w\-]+)/
289
+
290
+ # Extract classes
291
+ classes = selector_part.scan(/\.([\w\-]+)/).flatten
292
+ parts[:classes] = classes unless classes.empty?
293
+ end
294
+ elsif selector.start_with?('#')
295
+ # Just an ID
296
+ parts[:id] = selector[1..-1]
297
+ elsif selector.start_with?('.')
298
+ # Just a class
299
+ parts[:classes] = [selector[1..-1]]
300
+ else
301
+ # Just an element
302
+ parts[:element] = selector
303
+ end
304
+
305
+ parts
306
+ end
307
+
308
+ def matches_selector?(item, selector_parts)
309
+ # Handle child combinator selectors
310
+ if selector_parts[:combinator] == :child
311
+ return matches_child_selector?(item, selector_parts)
312
+ end
313
+
314
+ # Handle simple selectors
315
+ line = item[:stripped]
316
+ element_info = element_selector(line)
317
+
318
+ return false unless element_info
319
+
320
+ # Check element match
321
+ return false if selector_parts[:element] && !(element_info[:element] == selector_parts[:element])
322
+
323
+ # Check ID match
324
+ return false if selector_parts[:id] && !element_info[:selector].include?("##{selector_parts[:id]}")
325
+
326
+ # Check class matches
327
+ if selector_parts[:classes]
328
+ selector_parts[:classes].each do |cls|
329
+ return false unless element_info[:selector].include?(".#{cls}")
330
+ end
331
+ end
332
+
333
+ true
334
+ end
335
+
336
+ def matches_child_selector?(item, selector_parts)
337
+ # For child selector, we need to check if this item matches the child
338
+ # and verify its parent matches the parent selector
339
+
340
+ # First check if this item matches the child selector
341
+ return false unless matches_simple_selector?(item, selector_parts[:child])
342
+
343
+ # Then find its parent and check if it matches the parent selector
344
+ parent_item = find_parent_item(item)
345
+ return false unless parent_item
346
+
347
+ matches_simple_selector?(parent_item, selector_parts[:parent])
348
+ end
349
+
350
+ def matches_simple_selector?(item, selector_parts)
351
+ line = item[:stripped]
352
+ element_info = element_selector(line)
353
+
354
+ return false unless element_info
355
+
356
+ # Check element match
357
+ return false if selector_parts[:element] && !(element_info[:element] == selector_parts[:element])
358
+
359
+ # Check ID match
360
+ return false if selector_parts[:id] && !element_info[:selector].include?("##{selector_parts[:id]}")
361
+
362
+ # Check class matches
363
+ if selector_parts[:classes]
364
+ selector_parts[:classes].each do |cls|
365
+ return false unless element_info[:selector].include?(".#{cls}")
366
+ end
367
+ end
368
+
369
+ true
370
+ end
371
+
372
+ def find_parent_item(target_item)
373
+ # Find the parent of the target item by looking for the previous item
374
+ # with lower indentation level
375
+ target_indent = target_item[:indent_level]
376
+ target_line_num = target_item[:line_number]
377
+
378
+ # Search backwards from target item to find parent
379
+ return nil unless @current_structure
380
+
381
+ @current_structure.reverse.each do |item|
382
+ next if item[:line_number] >= target_line_num
383
+
384
+ if item[:indent_level] < target_indent
385
+ return item
386
+ end
387
+ end
388
+
389
+ nil
390
+ end
391
+
392
+ def clean_orphaned_comments(structure)
393
+ result = []
394
+
395
+ structure.each_with_index do |item, index|
396
+ # If this is a comment, check if the next non-comment item exists
397
+ if item[:type] == :html_comment
398
+ # Look ahead to see if there's meaningful content after this comment
399
+ has_following_content = false
400
+
401
+ (index + 1...structure.size).each do |next_index|
402
+ next_item = structure[next_index]
403
+
404
+ # If we find content at the same or lower indent level, keep the comment
405
+ if next_item[:indent_level] <= item[:indent_level] &&
406
+ next_item[:type] != :html_comment
407
+ has_following_content = true
408
+ break
409
+ end
410
+
411
+ # If we find indented content, keep the comment
412
+ if next_item[:indent_level] > item[:indent_level]
413
+ has_following_content = true
414
+ break
415
+ end
416
+ end
417
+
418
+ # Only keep the comment if there's following content
419
+ result << item if has_following_content
420
+ else
421
+ result << item
422
+ end
423
+ end
424
+
425
+ result
426
+ end
427
+ end
428
+ end
429
+ end
@@ -0,0 +1,145 @@
1
+ require_relative 'slim_manipulator'
2
+
3
+ module Blueprint
4
+ module Html2Slim
5
+ class SlimFixer < SlimManipulator
6
+ def fix_file(file_path)
7
+ content = read_file(file_path)
8
+ original_content = content.dup
9
+
10
+ fixes_applied = []
11
+
12
+ if options[:fix_slashes] != false
13
+ content, slash_fixes = fix_slash_prefix(content)
14
+ fixes_applied.concat(slash_fixes)
15
+ end
16
+
17
+ if options[:fix_multiline] != false
18
+ content, multiline_fixes = fix_multiline_text(content)
19
+ fixes_applied.concat(multiline_fixes)
20
+ end
21
+
22
+ if options[:dry_run]
23
+ if fixes_applied.any?
24
+ puts "\nChanges that would be made to #{file_path}:"
25
+ puts " Fixes: #{fixes_applied.join(", ")}"
26
+ show_diff(original_content, content) if options[:verbose]
27
+ else
28
+ puts "No issues found in #{file_path}"
29
+ end
30
+ elsif content != original_content
31
+ write_file(file_path, content)
32
+ end
33
+
34
+ { success: true, fixes: fixes_applied }
35
+ rescue StandardError => e
36
+ { success: false, error: e.message }
37
+ end
38
+
39
+ private
40
+
41
+ def fix_slash_prefix(content)
42
+ fixes = []
43
+ lines = content.split("\n")
44
+
45
+ lines.map!.with_index do |line, index|
46
+ stripped = line.strip
47
+ indent = line[/\A */]
48
+
49
+ # Fix text that starts with / after an element
50
+ if stripped =~ %r{^([a-z#.][^\s/]*)\s+(/[^/].*)$}i
51
+ element_part = ::Regexp.last_match(1)
52
+ text_part = ::Regexp.last_match(2)
53
+
54
+ # Convert to pipe notation
55
+ new_lines = [
56
+ "#{indent}#{element_part}",
57
+ "#{indent}#{" " * @indent_size}| #{text_part}"
58
+ ]
59
+
60
+ fixes << "slash text at line #{index + 1}"
61
+ new_lines
62
+ # Fix standalone text starting with slash (not a comment)
63
+ elsif stripped.start_with?('/') && !stripped.start_with?('/!') && !stripped.match?(%r{^/\s})
64
+ fixes << "slash text at line #{index + 1}"
65
+ "#{indent}| #{stripped}"
66
+ else
67
+ line
68
+ end
69
+ end
70
+
71
+ lines.flatten!
72
+ new_content = lines.join("\n")
73
+
74
+ [new_content, fixes]
75
+ end
76
+
77
+ def fix_multiline_text(content)
78
+ fixes = []
79
+ lines = content.split("\n")
80
+ result_lines = []
81
+ i = 0
82
+
83
+ while i < lines.size
84
+ line = lines[i]
85
+ stripped = line.strip
86
+ indent = line[/\A */]
87
+
88
+ # Check if this is an element with multiline text content
89
+ if stripped =~ /^([a-z#.][^\s]*)\s+(.+)$/i && i + 1 < lines.size
90
+ element_part = ::Regexp.last_match(1)
91
+ first_text = ::Regexp.last_match(2)
92
+
93
+ # Look ahead to see if next lines are continuation text
94
+ next_line_indent = lines[i + 1][/\A */]
95
+
96
+ if next_line_indent.size > indent.size && !lines[i + 1].strip.empty?
97
+ # This looks like multiline text that should use pipe notation
98
+ text_lines = [first_text]
99
+ j = i + 1
100
+
101
+ while j < lines.size
102
+ next_indent = lines[j][/\A */]
103
+ next_stripped = lines[j].strip
104
+
105
+ # Stop if we hit a line with same or less indentation
106
+ break if next_indent.size <= indent.size
107
+ # Stop if we hit Slim syntax
108
+ break if next_stripped =~ %r{^[=\-|/!#.]} || next_stripped =~ /^[a-z]+[#.\[]/i
109
+
110
+ text_lines << next_stripped
111
+ j += 1
112
+ end
113
+
114
+ if text_lines.size > 1
115
+ # Convert to proper multiline with pipes
116
+ result_lines << "#{indent}#{element_part}"
117
+ text_lines.each do |text|
118
+ result_lines << "#{indent}#{" " * @indent_size}| #{text}"
119
+ end
120
+
121
+ fixes << "multiline text at line #{i + 1}"
122
+ i = j
123
+ next
124
+ end
125
+ end
126
+ end
127
+
128
+ result_lines << line
129
+ i += 1
130
+ end
131
+
132
+ new_content = result_lines.join("\n")
133
+ [new_content, fixes]
134
+ end
135
+
136
+ def show_diff(original, modified)
137
+ puts "\n--- Original ---"
138
+ puts original
139
+ puts "\n+++ Modified +++"
140
+ puts modified
141
+ puts
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,117 @@
1
+ module Blueprint
2
+ module Html2Slim
3
+ class SlimManipulator
4
+ attr_reader :options
5
+
6
+ def initialize(options = {})
7
+ @options = options
8
+ @indent_size = options[:indent_size] || 2
9
+ end
10
+
11
+ protected
12
+
13
+ def read_file(file_path)
14
+ File.read(file_path, encoding: 'UTF-8')
15
+ end
16
+
17
+ def write_file(file_path, content)
18
+ return if options[:dry_run]
19
+
20
+ # Create backup if requested
21
+ if options[:backup]
22
+ backup_path = "#{file_path}.bak"
23
+ File.write(backup_path, read_file(file_path), encoding: 'UTF-8')
24
+ end
25
+
26
+ # Ensure content ends with newline
27
+ content += "\n" unless content.end_with?("\n")
28
+ File.write(file_path, content, encoding: 'UTF-8')
29
+ end
30
+
31
+ def parse_slim_structure(content)
32
+ lines = content.split("\n")
33
+ structure = []
34
+
35
+ lines.each_with_index do |line, index|
36
+ indent_level = line[/\A */].size / @indent_size
37
+ stripped = line.strip
38
+
39
+ next if stripped.empty?
40
+
41
+ structure << {
42
+ line: line,
43
+ stripped: stripped,
44
+ indent_level: indent_level,
45
+ line_number: index + 1,
46
+ type: detect_line_type(stripped)
47
+ }
48
+ end
49
+
50
+ structure
51
+ end
52
+
53
+ def detect_line_type(line)
54
+ case line
55
+ when /^doctype/i
56
+ :doctype
57
+ when %r{^/!}
58
+ :html_comment
59
+ when %r{^/\s}
60
+ :slim_comment
61
+ when /^-/
62
+ :ruby_code
63
+ when /^=/
64
+ :ruby_output
65
+ when /^ruby:/
66
+ :ruby_block
67
+ when /^\|/
68
+ :text_pipe
69
+ when /^[#.]/
70
+ :div_shorthand
71
+ when /^[a-z][a-z0-9]*/i
72
+ :element
73
+ else
74
+ :text
75
+ end
76
+ end
77
+
78
+ def indent_string(level)
79
+ ' ' * (@indent_size * level)
80
+ end
81
+
82
+ def rebuild_slim(structure)
83
+ structure.map do |item|
84
+ item[:modified_line] || item[:line]
85
+ end.join("\n")
86
+ end
87
+
88
+ def element_selector(line)
89
+ # Extract element, id, and classes from a Slim line
90
+ match = line.match(/^([a-z][a-z0-9]*)?([#.][\w\-#.]*)?/i)
91
+ return nil unless match
92
+
93
+ {
94
+ element: match[1] || 'div',
95
+ selector: match[2] || '',
96
+ full: match[0]
97
+ }
98
+ end
99
+
100
+ def has_slash_prefix_text?(line)
101
+ # Check if line has text that starts with forward slash
102
+ # This is a common issue that needs fixing
103
+ stripped = line.strip
104
+
105
+ # Check for inline text after element
106
+ if stripped =~ /^[a-z#.]/i
107
+ # Extract the text part after element definition
108
+ text_part = stripped.sub(/^[a-z][a-z0-9]*([#.][\w\-#.]*)?(\[.*?\])?/i, '').strip
109
+ return text_part.start_with?('/')
110
+ end
111
+
112
+ # Check for standalone text that starts with slash
113
+ stripped.start_with?('/') && !stripped.start_with?('/!') && !stripped.match?(%r{^/\s})
114
+ end
115
+ end
116
+ end
117
+ end