blueprint-html2slim 1.1.0 → 1.3.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/CHANGELOG.md +70 -5
- data/README.md +360 -35
- data/bin/slimtool +238 -0
- data/lib/blueprint/html2slim/link_extractor.rb +203 -0
- data/lib/blueprint/html2slim/slim_extractor.rb +286 -0
- data/lib/blueprint/html2slim/slim_fixer.rb +145 -0
- data/lib/blueprint/html2slim/slim_manipulator.rb +117 -0
- data/lib/blueprint/html2slim/slim_railsifier.rb +254 -0
- data/lib/blueprint/html2slim/slim_validator.rb +170 -0
- data/lib/blueprint/html2slim/version.rb +1 -1
- metadata +9 -1
@@ -0,0 +1,286 @@
|
|
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 head nav header footer script]
|
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
|
+
# Rebuild the Slim content
|
36
|
+
new_content = rebuild_extracted_content(extracted)
|
37
|
+
|
38
|
+
# Write to output file
|
39
|
+
output_path = options[:output] || file_path.sub(/\.slim$/, '_extracted.slim')
|
40
|
+
write_file(output_path, new_content)
|
41
|
+
|
42
|
+
# Build appropriate response based on extraction mode
|
43
|
+
if options[:outline]
|
44
|
+
{
|
45
|
+
success: true,
|
46
|
+
mode: 'outline',
|
47
|
+
depth: options[:outline]
|
48
|
+
}
|
49
|
+
elsif options[:selector]
|
50
|
+
{
|
51
|
+
success: true,
|
52
|
+
mode: 'selector',
|
53
|
+
selector: options[:selector]
|
54
|
+
}
|
55
|
+
else
|
56
|
+
{
|
57
|
+
success: true,
|
58
|
+
removed: sections_to_remove,
|
59
|
+
kept: sections_to_keep.empty? ? nil : sections_to_keep
|
60
|
+
}
|
61
|
+
end
|
62
|
+
rescue StandardError => e
|
63
|
+
{ success: false, error: e.message }
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
def normalize_selectors(selectors)
|
69
|
+
return [] unless selectors
|
70
|
+
|
71
|
+
selectors.flat_map do |selector|
|
72
|
+
selector.split(',').map(&:strip).map(&:downcase)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def extract_content(structure, keep_selectors, remove_selectors)
|
77
|
+
result = []
|
78
|
+
skip_until_indent = nil
|
79
|
+
keep_until_indent = nil
|
80
|
+
|
81
|
+
structure.each_with_index do |item, _index|
|
82
|
+
# If we're in skip mode, check if we've exited the skipped section
|
83
|
+
if skip_until_indent
|
84
|
+
next if item[:indent_level] > skip_until_indent
|
85
|
+
|
86
|
+
skip_until_indent = nil
|
87
|
+
# Continue processing this line
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
# If we're in keep mode, track when we exit
|
92
|
+
keep_until_indent = nil if keep_until_indent && item[:indent_level] <= keep_until_indent
|
93
|
+
|
94
|
+
# Determine what to do with this line
|
95
|
+
if !keep_selectors.empty?
|
96
|
+
# We have keep selectors - only keep matching sections
|
97
|
+
if should_keep_line?(item, keep_selectors)
|
98
|
+
# This line matches a keep selector
|
99
|
+
keep_until_indent = item[:indent_level]
|
100
|
+
result << item
|
101
|
+
elsif keep_until_indent && item[:indent_level] > keep_until_indent
|
102
|
+
# We're inside a kept section
|
103
|
+
result << item
|
104
|
+
end
|
105
|
+
# Otherwise, skip this line
|
106
|
+
elsif should_remove_line?(item, remove_selectors)
|
107
|
+
# No keep selectors - use remove logic
|
108
|
+
skip_until_indent = item[:indent_level]
|
109
|
+
next
|
110
|
+
# Skip this line and all its children
|
111
|
+
else
|
112
|
+
result << item
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
result
|
117
|
+
end
|
118
|
+
|
119
|
+
def should_remove_line?(item, selectors)
|
120
|
+
return false if selectors.empty?
|
121
|
+
|
122
|
+
line = item[:stripped]
|
123
|
+
|
124
|
+
selectors.any? do |selector|
|
125
|
+
case selector
|
126
|
+
when 'doctype'
|
127
|
+
item[:type] == :doctype
|
128
|
+
when 'script', 'style', 'link', 'meta'
|
129
|
+
line.start_with?(selector)
|
130
|
+
else
|
131
|
+
# Check for element match or class/id match
|
132
|
+
element_info = element_selector(line)
|
133
|
+
if element_info
|
134
|
+
element_info[:element] == selector ||
|
135
|
+
element_info[:selector].include?(".#{selector}") ||
|
136
|
+
element_info[:selector].include?("##{selector}")
|
137
|
+
else
|
138
|
+
false
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def should_keep_line?(item, selectors)
|
145
|
+
return true if selectors.empty?
|
146
|
+
|
147
|
+
line = item[:stripped]
|
148
|
+
|
149
|
+
selectors.any? do |selector|
|
150
|
+
element_info = element_selector(line)
|
151
|
+
if element_info
|
152
|
+
element_info[:element] == selector ||
|
153
|
+
element_info[:selector].include?(".#{selector}") ||
|
154
|
+
element_info[:selector].include?("##{selector}")
|
155
|
+
else
|
156
|
+
false
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def remove_outer_wrapper(structure)
|
162
|
+
return structure if structure.empty?
|
163
|
+
|
164
|
+
# Find the minimum indentation level
|
165
|
+
min_indent = structure.map { |item| item[:indent_level] }.min
|
166
|
+
|
167
|
+
# If there's only one element at the minimum level, remove it
|
168
|
+
root_elements = structure.select { |item| item[:indent_level] == min_indent }
|
169
|
+
|
170
|
+
if root_elements.size == 1 && root_elements.first[:type] == :element
|
171
|
+
# Remove the wrapper and decrease indentation of all children
|
172
|
+
structure = structure[1..-1].map do |item|
|
173
|
+
item[:indent_level] -= 1 if item[:indent_level] > min_indent
|
174
|
+
item
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
structure
|
179
|
+
end
|
180
|
+
|
181
|
+
def rebuild_extracted_content(structure)
|
182
|
+
return '' if structure.empty?
|
183
|
+
|
184
|
+
# Normalize indentation - find minimum and adjust
|
185
|
+
min_indent = structure.map { |item| item[:indent_level] }.min || 0
|
186
|
+
|
187
|
+
structure.map do |item|
|
188
|
+
adjusted_indent = item[:indent_level] - min_indent
|
189
|
+
indent_string(adjusted_indent) + item[:stripped]
|
190
|
+
end.join("\n")
|
191
|
+
end
|
192
|
+
|
193
|
+
def extract_outline(structure, max_depth)
|
194
|
+
result = []
|
195
|
+
|
196
|
+
structure.each do |item|
|
197
|
+
# Include items up to the specified depth
|
198
|
+
result << item if item[:indent_level] < max_depth
|
199
|
+
end
|
200
|
+
|
201
|
+
result
|
202
|
+
end
|
203
|
+
|
204
|
+
def extract_by_selector(structure, selector)
|
205
|
+
result = []
|
206
|
+
in_selected_section = false
|
207
|
+
selected_indent = nil
|
208
|
+
|
209
|
+
# Parse the CSS selector
|
210
|
+
selector_parts = parse_css_selector(selector)
|
211
|
+
|
212
|
+
structure.each do |item|
|
213
|
+
# Check if we're exiting a selected section
|
214
|
+
if in_selected_section && selected_indent && item[:indent_level] <= selected_indent
|
215
|
+
in_selected_section = false
|
216
|
+
selected_indent = nil
|
217
|
+
end
|
218
|
+
|
219
|
+
# Check if this item matches the selector
|
220
|
+
if !in_selected_section && matches_selector?(item, selector_parts)
|
221
|
+
in_selected_section = true
|
222
|
+
selected_indent = item[:indent_level]
|
223
|
+
result << item
|
224
|
+
elsif in_selected_section
|
225
|
+
result << item
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
result
|
230
|
+
end
|
231
|
+
|
232
|
+
def parse_css_selector(selector)
|
233
|
+
# Support basic CSS selectors: element, #id, .class, element.class, element#id
|
234
|
+
parts = {}
|
235
|
+
|
236
|
+
# Handle complex selectors like div.container#main
|
237
|
+
if selector =~ /^([a-z][a-z0-9]*)?([#.][\w\-#.]*)?$/i
|
238
|
+
parts[:element] = ::Regexp.last_match(1)
|
239
|
+
selector_part = ::Regexp.last_match(2)
|
240
|
+
|
241
|
+
if selector_part
|
242
|
+
# Extract ID
|
243
|
+
parts[:id] = ::Regexp.last_match(1) if selector_part =~ /#([\w\-]+)/
|
244
|
+
|
245
|
+
# Extract classes
|
246
|
+
classes = selector_part.scan(/\.([\w\-]+)/).flatten
|
247
|
+
parts[:classes] = classes unless classes.empty?
|
248
|
+
end
|
249
|
+
elsif selector.start_with?('#')
|
250
|
+
# Just an ID
|
251
|
+
parts[:id] = selector[1..-1]
|
252
|
+
elsif selector.start_with?('.')
|
253
|
+
# Just a class
|
254
|
+
parts[:classes] = [selector[1..-1]]
|
255
|
+
else
|
256
|
+
# Just an element
|
257
|
+
parts[:element] = selector
|
258
|
+
end
|
259
|
+
|
260
|
+
parts
|
261
|
+
end
|
262
|
+
|
263
|
+
def matches_selector?(item, selector_parts)
|
264
|
+
line = item[:stripped]
|
265
|
+
element_info = element_selector(line)
|
266
|
+
|
267
|
+
return false unless element_info
|
268
|
+
|
269
|
+
# Check element match
|
270
|
+
return false if selector_parts[:element] && !(element_info[:element] == selector_parts[:element])
|
271
|
+
|
272
|
+
# Check ID match
|
273
|
+
return false if selector_parts[:id] && !element_info[:selector].include?("##{selector_parts[:id]}")
|
274
|
+
|
275
|
+
# Check class matches
|
276
|
+
if selector_parts[:classes]
|
277
|
+
selector_parts[:classes].each do |cls|
|
278
|
+
return false unless element_info[:selector].include?(".#{cls}")
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
true
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
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
|