blueprint-html2slim 1.0.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.
data/bin/slimtool ADDED
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env ruby
2
+ require 'thor'
3
+ require 'pathname'
4
+ require_relative '../lib/blueprint/html2slim'
5
+
6
+ class SlimToolCLI < Thor
7
+ def self.exit_on_failure?
8
+ true
9
+ end
10
+
11
+ class_option :help, aliases: '-h', type: :boolean, desc: 'Show help'
12
+
13
+ desc 'fix FILE [FILE2 ...]', 'Fix common Slim syntax issues'
14
+ option :fix_slashes, type: :boolean, default: true, desc: 'Fix forward slashes that should be text (e.g., span /month)'
15
+ option :fix_multiline, type: :boolean, default: true, desc: 'Fix multiline text blocks using pipe notation'
16
+ option :backup, aliases: '-b', type: :boolean, desc: 'Create .bak backup before fixing'
17
+ option :dry_run, aliases: '-n', type: :boolean, desc: 'Preview changes without modifying files'
18
+ def fix(*files)
19
+ if files.empty?
20
+ puts 'Error: No files specified'
21
+ puts 'Usage: slimtool fix FILE [FILE2 ...]'
22
+ exit 1
23
+ end
24
+
25
+ require_relative '../lib/blueprint/html2slim/slim_fixer'
26
+ fixer = Blueprint::Html2Slim::SlimFixer.new(options)
27
+
28
+ files.each do |file|
29
+ if File.exist?(file)
30
+ result = fixer.fix_file(file)
31
+ if result[:success]
32
+ puts "Fixed: #{file}"
33
+ puts " Issues fixed: #{result[:fixes].join(", ")}" if result[:fixes].any?
34
+ else
35
+ puts "Error fixing #{file}: #{result[:error]}"
36
+ end
37
+ else
38
+ puts "File not found: #{file}"
39
+ end
40
+ end
41
+ end
42
+
43
+ desc 'extract FILE', 'Extract content sections from Slim file'
44
+ option :keep, type: :array, desc: 'Keep only specified sections (e.g., main, article, .content)'
45
+ option :remove, type: :array, desc: 'Remove specified sections (e.g., head, nav, footer, script)'
46
+ option :output, aliases: '-o', desc: 'Output file path (default: FILE_extracted.slim)'
47
+ option :remove_wrapper, type: :boolean, desc: 'Remove single outer wrapper element if present'
48
+ option :outline, type: :numeric, desc: 'Extract outline up to depth N (e.g., 2 = top two levels only)'
49
+ option :selector, type: :string, desc: 'CSS selector for fragment extraction (#id, .class, element)'
50
+ def extract(file)
51
+ unless File.exist?(file)
52
+ puts "Error: File not found: #{file}"
53
+ exit 1
54
+ end
55
+
56
+ require_relative '../lib/blueprint/html2slim/slim_extractor'
57
+ extractor = Blueprint::Html2Slim::SlimExtractor.new(options)
58
+
59
+ result = extractor.extract_file(file)
60
+ if result[:success]
61
+ output_path = options[:output] || file.sub(/\.slim$/, '_extracted.slim')
62
+ puts "Extracted content to: #{output_path}"
63
+ puts " Removed: #{result[:removed].join(", ")}" if result[:removed]&.any?
64
+ puts " Kept: #{result[:kept].join(", ")}" if result[:kept]&.any?
65
+ else
66
+ puts "Error extracting from #{file}: #{result[:error]}"
67
+ end
68
+ end
69
+
70
+ desc 'validate FILE [FILE2 ...]', 'Validate Slim syntax and check for issues'
71
+ option :strict, type: :boolean, desc: 'Enable strict mode (check tabs, inline styles, etc.)'
72
+ option :check_rails, type: :boolean, desc: 'Check Rails conventions (helpers, CSRF, asset pipeline)'
73
+ def validate(*files)
74
+ if files.empty?
75
+ puts 'Error: No files specified'
76
+ puts 'Usage: slimtool validate FILE [FILE2 ...]'
77
+ exit 1
78
+ end
79
+
80
+ require_relative '../lib/blueprint/html2slim/slim_validator'
81
+ validator = Blueprint::Html2Slim::SlimValidator.new(options)
82
+
83
+ total_errors = 0
84
+ total_warnings = 0
85
+
86
+ files.each do |file|
87
+ if File.exist?(file)
88
+ result = validator.validate_file(file)
89
+ if result[:valid]
90
+ puts "✓ #{file} - Valid"
91
+ else
92
+ puts "✗ #{file} - Invalid"
93
+ result[:errors]&.each { |error| puts " ERROR: #{error}" }
94
+ total_errors += result[:errors]&.size || 0
95
+ end
96
+
97
+ if result[:warnings]&.any?
98
+ result[:warnings].each { |warning| puts " WARNING: #{warning}" }
99
+ total_warnings += result[:warnings].size
100
+ end
101
+ else
102
+ puts "File not found: #{file}"
103
+ total_errors += 1
104
+ end
105
+ end
106
+
107
+ puts "\nValidation complete: #{total_errors} error(s), #{total_warnings} warning(s)"
108
+ exit(1) if total_errors.positive?
109
+ end
110
+
111
+ desc 'railsify FILE [FILE2 ...]', 'Convert static Slim to Rails conventions'
112
+ option :add_helpers, type: :boolean, default: true, desc: 'Convert links to Rails helpers (requires --mappings)'
113
+ option :use_assets, type: :boolean, default: true, desc: 'Convert CDN assets to Rails asset pipeline'
114
+ option :add_csrf, type: :boolean, desc: 'Add CSRF meta tags to head section'
115
+ option :backup, aliases: '-b', type: :boolean, desc: 'Create .bak backup before converting'
116
+ option :dry_run, aliases: '-n', type: :boolean, desc: 'Preview changes without modifying files'
117
+ option :mappings, type: :string, desc: 'JSON/YAML file with URL-to-helper mappings (required for link conversion)'
118
+ def railsify(*files)
119
+ if files.empty?
120
+ puts 'Error: No files specified'
121
+ puts 'Usage: slimtool railsify FILE [FILE2 ...]'
122
+ exit 1
123
+ end
124
+
125
+ require_relative '../lib/blueprint/html2slim/slim_railsifier'
126
+ railsifier = Blueprint::Html2Slim::SlimRailsifier.new(options)
127
+
128
+ files.each do |file|
129
+ if File.exist?(file)
130
+ result = railsifier.railsify_file(file)
131
+ if result[:success]
132
+ puts "Railsified: #{file}"
133
+ puts " Conversions: #{result[:conversions].join(", ")}" if result[:conversions]&.any?
134
+ else
135
+ puts "Error converting #{file}: #{result[:error]}"
136
+ end
137
+ else
138
+ puts "File not found: #{file}"
139
+ end
140
+ end
141
+ end
142
+
143
+ desc 'extract-links FILE [FILE2 ...]', 'Find and report all hardcoded links'
144
+ option :output, aliases: '-o', desc: 'Save results to file (auto-detect format by extension)'
145
+ option :format, default: 'json', enum: %w[json yaml text], desc: 'Output format (json, yaml, or text)'
146
+ def extract_links(*files)
147
+ if files.empty?
148
+ puts 'Error: No files specified'
149
+ puts 'Usage: slimtool extract-links FILE [FILE2 ...]'
150
+ exit 1
151
+ end
152
+
153
+ require_relative '../lib/blueprint/html2slim/link_extractor'
154
+ extractor = Blueprint::Html2Slim::LinkExtractor.new(options)
155
+
156
+ all_links = {}
157
+
158
+ files.each do |file|
159
+ if File.exist?(file)
160
+ result = extractor.extract_links(file)
161
+ if result[:success]
162
+ all_links[file] = result[:links] if result[:links].any?
163
+ else
164
+ puts "Error extracting from #{file}: #{result[:error]}"
165
+ end
166
+ else
167
+ puts "File not found: #{file}"
168
+ end
169
+ end
170
+
171
+ if all_links.any?
172
+ if options[:output]
173
+ extractor.save_links(all_links, options[:output])
174
+ puts "Extracted links saved to: #{options[:output]}"
175
+ else
176
+ extractor.display_links(all_links)
177
+ end
178
+
179
+ total = all_links.values.flatten.size
180
+ puts "\nTotal: #{total} hardcoded link(s) found in #{all_links.keys.size} file(s)"
181
+ else
182
+ puts 'No hardcoded links found'
183
+ end
184
+ end
185
+
186
+ desc 'version', 'Show version'
187
+ def version
188
+ require_relative '../lib/blueprint/html2slim/version'
189
+ puts "slimtool #{Blueprint::Html2Slim::VERSION}"
190
+ end
191
+
192
+ def self.start(given_args = ARGV, config = {})
193
+ # Show general help for no args or 'help' without specific command
194
+ if given_args.empty? || (given_args == ['help']) || (given_args == ['-h']) || (given_args == ['--help'])
195
+ puts 'Slim Template Manipulation Tool'
196
+ puts ''
197
+ puts 'Usage: slimtool COMMAND [OPTIONS] FILE [FILE2 ...]'
198
+ puts ''
199
+ puts 'Commands:'
200
+ puts ' fix Fix common Slim syntax issues'
201
+ puts ' extract Extract content sections from Slim files'
202
+ puts ' validate Validate Slim syntax'
203
+ puts ' railsify Convert to Rails conventions'
204
+ puts ' extract-links Extract hardcoded links from templates'
205
+ puts ' version Show version'
206
+ puts ' help Show this help message'
207
+ puts ''
208
+ puts 'Examples:'
209
+ puts ' # Fix text starting with slash'
210
+ puts ' slimtool fix pricing.slim'
211
+ puts ''
212
+ puts ' # Extract main content, removing navigation'
213
+ puts ' slimtool extract index.slim --remove head,nav,footer'
214
+ puts ''
215
+ puts ' # Extract high-level structure only'
216
+ puts ' slimtool extract page.slim --outline 2'
217
+ puts ''
218
+ puts ' # Extract specific section by CSS selector'
219
+ puts ' slimtool extract page.slim --selector "#content"'
220
+ puts ''
221
+ puts ' # Validate with strict style checking'
222
+ puts ' slimtool validate *.slim --strict --check-rails'
223
+ puts ''
224
+ puts ' # Convert to Rails helpers with custom mappings'
225
+ puts ' slimtool railsify template.slim --mappings urls.json'
226
+ puts ''
227
+ puts ' # Find all hardcoded links'
228
+ puts ' slimtool extract-links views/**/*.slim -o links.json'
229
+ puts ''
230
+ puts 'Run "slimtool COMMAND --help" for more information on a command.'
231
+ exit 0
232
+ else
233
+ super
234
+ end
235
+ end
236
+ end
237
+
238
+ SlimToolCLI.start(ARGV)
@@ -17,25 +17,25 @@ module Blueprint
17
17
 
18
18
  def convert(html_content)
19
19
  lines = []
20
-
20
+
21
21
  # Handle DOCTYPE declaration
22
22
  if html_content =~ /<!DOCTYPE\s+(.+?)>/i
23
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
24
+ lines << if doctype_content =~ /strict/i
25
+ 'doctype strict'
26
+ elsif doctype_content =~ /transitional/i
27
+ 'doctype transitional'
28
+ elsif doctype_content =~ /frameset/i
29
+ 'doctype frameset'
30
+ elsif doctype_content =~ /html$/i
31
+ 'doctype html'
32
+ else
33
+ 'doctype'
34
+ end
35
35
  # Remove DOCTYPE from content for further processing
36
36
  html_content = html_content.sub(/<!DOCTYPE\s+.+?>/i, '')
37
37
  end
38
-
38
+
39
39
  html_content = preprocess_erb(html_content)
40
40
  # Use HTML.parse for full documents, DocumentFragment for fragments
41
41
  if html_content =~ /<html/i
@@ -68,7 +68,7 @@ module Blueprint
68
68
  def preprocess_erb(content)
69
69
  # Convert ERB blocks to span elements to preserve hierarchy
70
70
  # This approach is inspired by the original html2slim gem
71
-
71
+
72
72
  # Keep ERB tags in attributes unchanged by temporarily replacing them
73
73
  erb_in_attrs = []
74
74
  content = content.gsub(/(<[^>]*)(<%=?.+?%>)([^>]*>)/) do
@@ -79,65 +79,79 @@ module Blueprint
79
79
  erb_in_attrs << erb
80
80
  "#{before}#{placeholder}#{after}"
81
81
  end
82
-
82
+
83
83
  # Handle multi-line ERB blocks first (with m flag for multiline)
84
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")
85
+ code_block = ::Regexp.last_match(1).strip
86
+ # Mark as multiline block for special handling
87
+ "<!--ERB_MULTILINE_CODE:#{code_block.gsub("-->", "__ARROW__")}-->"
88
+ end
89
+
90
+ # Handle multiline ERB output blocks (e.g., <%= form_with(...) spanning multiple lines %>)
91
+ content = content.gsub(/<%=\s*\n(.*?)\n\s*-?%>/m) do
92
+ code_block = ::Regexp.last_match(1).strip
93
+ # Check if it ends with do block
94
+ if code_block =~ /\bdo\s*(\|[^|]*\|)?\s*$/
95
+ # Convert to single line for block processing
96
+ single_line = code_block.gsub(/\s+/, ' ')
97
+ "<%= #{single_line} %>" # Keep for block processing
98
+ else
99
+ # Mark as multiline output for special handling
100
+ "<!--ERB_MULTILINE_OUTPUT:#{code_block.gsub("-->", "__ARROW__")}-->"
101
+ end
88
102
  end
89
-
103
+
90
104
  # Convert simple ERB output tags that don't create blocks
91
105
  # This prevents them from being caught by the block regex
92
106
  content = content.gsub(/<%=\s*([^%]+?)\s*%>/) do
93
107
  code = ::Regexp.last_match(1).strip
94
108
  # Skip if it's a do block
95
109
  if code =~ /\bdo\s*(\|[^|]*\|)?\s*$/
96
- "<%= #{code} %>" # Keep original, will be processed later
110
+ "<%= #{code} %>" # Keep original, will be processed later
97
111
  else
98
112
  %(<!--ERB_OUTPUT:#{code}-->)
99
113
  end
100
114
  end
101
-
115
+
102
116
  # Convert ERB blocks that create structure (do...end, if...end, etc.)
103
117
  # 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
118
+ content = content.gsub(/<%(-|=)?\s*((\s*(case|if|for|unless|until|while) .+?)|.+?do\s*(\|[^|]*\|)?\s*)-?%>/m) do
105
119
  type = ::Regexp.last_match(1)
106
120
  code = ::Regexp.last_match(2).strip
107
121
  # Preserve whether it was = or - in the code attribute
108
122
  prefix = type == '=' ? '=' : ''
109
- %(<span erb-code="#{prefix}#{code.gsub('"', '&quot;')}">)
123
+ %(<span erb-code="#{prefix}#{code.gsub('"', "&quot;")}">)
110
124
  end
111
-
125
+
112
126
  # Handle else
113
127
  content = content.gsub(/<%-?\s*else\s*-?%>/, %(</span><span erb-code="else">))
114
-
128
+
115
129
  # Handle elsif
116
130
  content = content.gsub(/<%-?\s*(elsif .+?)\s*-?%>/) do
117
131
  code = ::Regexp.last_match(1).strip
118
- %(</span><span erb-code="#{code.gsub('"', '&quot;')}">)
132
+ %(</span><span erb-code="#{code.gsub('"', "&quot;")}">)
119
133
  end
120
-
134
+
121
135
  # Handle when
122
136
  content = content.gsub(/<%-?\s*(when .+?)\s*-?%>/) do
123
137
  code = ::Regexp.last_match(1).strip
124
- %(</span><span erb-code="#{code.gsub('"', '&quot;')}">)
138
+ %(</span><span erb-code="#{code.gsub('"', "&quot;")}">)
125
139
  end
126
-
140
+
127
141
  # Handle end statements - close the span
128
142
  content = content.gsub(/<%\s*(end|}|end\s+-)\s*%>/, %(</span>))
129
-
143
+
130
144
  # Convert any remaining ERB code tags to comments
131
145
  content = content.gsub(/<%-?\s*(.+?)\s*%>/) do
132
146
  code = ::Regexp.last_match(1).strip
133
147
  %(<!--ERB_CODE:#{code}-->)
134
148
  end
135
-
149
+
136
150
  # Restore ERB tags in attributes
137
151
  erb_in_attrs.each_with_index do |erb, i|
138
152
  content = content.gsub("ERB_IN_ATTR_#{i}", erb)
139
153
  end
140
-
154
+
141
155
  content
142
156
  end
143
157
 
@@ -161,24 +175,24 @@ module Blueprint
161
175
  # Check if this is an ERB span element
162
176
  if node.name == 'span' && node['erb-code']
163
177
  erb_code = node['erb-code'].gsub('&quot;', '"')
164
-
178
+
165
179
  # 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
-
180
+ lines << if erb_code =~ /^(if|unless|case|for|while|elsif|else|when)\b/
181
+ "#{indent}- #{erb_code}"
182
+ elsif erb_code.start_with?('=')
183
+ # It was originally <%= ... %>, use = prefix
184
+ "#{indent}= #{erb_code[1..-1].strip}"
185
+ else
186
+ # It was originally <% ... %>, use - prefix
187
+ "#{indent}- #{erb_code}"
188
+ end
189
+
176
190
  # Process children with increased depth
177
191
  node.children.each do |child|
178
192
  child_lines = process_node(child, depth + 1)
179
193
  lines.concat(child_lines) unless child_lines.empty?
180
194
  end
181
-
195
+
182
196
  return lines
183
197
  end
184
198
 
@@ -206,6 +220,10 @@ module Blueprint
206
220
  text = process_inline_text(text.strip)
207
221
  if text.empty?
208
222
  lines << "#{indent}#{tag_line}"
223
+ elsif text.start_with?('/')
224
+ # Text starting with / needs pipe notation to avoid being treated as comment
225
+ lines << "#{indent}#{tag_line}"
226
+ lines << "#{" " * ((depth + 1) * @indent_size)}| #{text}"
209
227
  else
210
228
  lines << "#{indent}#{tag_line} #{text}"
211
229
  end
@@ -227,7 +245,7 @@ module Blueprint
227
245
  # Strip and split classes, filtering out empty strings
228
246
  classes = node['class']&.strip&.split(/\s+/)&.reject(&:empty?) || []
229
247
  attributes = collect_attributes(node)
230
-
248
+
231
249
  # Treat empty id as no id
232
250
  id = nil if id && id.strip.empty?
233
251
 
@@ -299,14 +317,36 @@ module Blueprint
299
317
  # Extract indentation level if present
300
318
  extra_indent = 0
301
319
  if comment_text =~ /:INDENT:(\d+)$/
302
- extra_indent = $1.to_i
320
+ extra_indent = ::Regexp.last_match(1).to_i
303
321
  comment_text = comment_text.sub(/:INDENT:\d+$/, '')
304
322
  end
305
-
323
+
306
324
  total_depth = depth + extra_indent
307
325
  indent = ' ' * (total_depth * @indent_size)
308
326
 
309
- if comment_text.start_with?('ERB_OUTPUT_BLOCK:')
327
+ if comment_text.start_with?('ERB_MULTILINE_CODE:')
328
+ erb_content = comment_text.sub('ERB_MULTILINE_CODE:', '').gsub('__ARROW__', '-->')
329
+ # Use ruby: block for multiline code
330
+ lines = ["#{indent}ruby:"]
331
+ erb_content.lines.each do |line|
332
+ lines << "#{indent} #{line.rstrip}"
333
+ end
334
+ lines
335
+ elsif comment_text.start_with?('ERB_MULTILINE_OUTPUT:')
336
+ erb_content = comment_text.sub('ERB_MULTILINE_OUTPUT:', '').gsub('__ARROW__', '-->')
337
+ # For multiline output, use line continuation
338
+ lines = erb_content.lines.map(&:rstrip)
339
+ if lines.length == 1
340
+ ["#{indent}= #{lines[0]}"]
341
+ else
342
+ result = ["#{indent}= #{lines[0]} \\"]
343
+ lines[1..-2].each do |line|
344
+ result << "#{indent} #{line} \\"
345
+ end
346
+ result << "#{indent} #{lines[-1]}" if lines.length > 1
347
+ result
348
+ end
349
+ elsif comment_text.start_with?('ERB_OUTPUT_BLOCK:')
310
350
  erb_content = comment_text.sub('ERB_OUTPUT_BLOCK:', '')
311
351
  ["#{indent}= #{erb_content}"]
312
352
  elsif comment_text.start_with?('ERB_OUTPUT:')
@@ -0,0 +1,203 @@
1
+ require_relative 'slim_manipulator'
2
+ require 'json'
3
+ require 'yaml'
4
+
5
+ module Blueprint
6
+ module Html2Slim
7
+ class LinkExtractor < SlimManipulator
8
+ def extract_links(file_path)
9
+ content = read_file(file_path)
10
+ structure = parse_slim_structure(content)
11
+
12
+ links = []
13
+
14
+ structure.each do |item|
15
+ line = item[:stripped]
16
+ line_num = item[:line_number]
17
+
18
+ # Extract links from anchor tags
19
+ if line =~ /^(\s*)a\[.*href=["']([^"']+)["']/
20
+ href = ::Regexp.last_match(2)
21
+ # Identify hardcoded links (not Rails helpers, not variables)
22
+ if is_hardcoded_link?(href)
23
+ links << {
24
+ line: line_num,
25
+ type: 'anchor',
26
+ href: href,
27
+ full_line: line,
28
+ suggested_helper: suggest_rails_helper(href)
29
+ }
30
+ end
31
+ end
32
+
33
+ # Extract links from form actions
34
+ if line =~ /^(\s*)form\[.*action=["']([^"']+)["']/
35
+ action = ::Regexp.last_match(2)
36
+ if is_hardcoded_link?(action)
37
+ links << {
38
+ line: line_num,
39
+ type: 'form',
40
+ href: action,
41
+ full_line: line,
42
+ suggested_helper: suggest_rails_helper(action)
43
+ }
44
+ end
45
+ end
46
+
47
+ # Extract stylesheet links
48
+ if line =~ /link\[.*href=["']([^"']+\.css[^"']*)["']/
49
+ href = ::Regexp.last_match(1)
50
+ if !href.start_with?('http') && !href.start_with?('//')
51
+ links << {
52
+ line: line_num,
53
+ type: 'stylesheet',
54
+ href: href,
55
+ full_line: line,
56
+ suggested_helper: "stylesheet_link_tag '#{File.basename(href, ".*")}'"
57
+ }
58
+ end
59
+ end
60
+
61
+ # Extract script sources
62
+ if line =~ /script\[.*src=["']([^"']+\.js[^"']*)["']/
63
+ src = ::Regexp.last_match(1)
64
+ if !src.start_with?('http') && !src.start_with?('//')
65
+ links << {
66
+ line: line_num,
67
+ type: 'javascript',
68
+ href: src,
69
+ full_line: line,
70
+ suggested_helper: "javascript_include_tag '#{File.basename(src, ".*")}'"
71
+ }
72
+ end
73
+ end
74
+
75
+ # Extract image sources
76
+ next unless line =~ /img\[.*src=["']([^"']+)["']/
77
+
78
+ src = ::Regexp.last_match(1)
79
+ next unless !src.start_with?('http') && !src.start_with?('//') && !src.include?('<%')
80
+
81
+ links << {
82
+ line: line_num,
83
+ type: 'image',
84
+ href: src,
85
+ full_line: line,
86
+ suggested_helper: "image_tag '#{src}'"
87
+ }
88
+ end
89
+
90
+ { success: true, links: links }
91
+ rescue StandardError => e
92
+ { success: false, error: e.message }
93
+ end
94
+
95
+ def save_links(all_links, output_path)
96
+ format = options[:format] || 'json'
97
+
98
+ case format
99
+ when 'json'
100
+ File.write(output_path, JSON.pretty_generate(all_links))
101
+ when 'yaml'
102
+ File.write(output_path, all_links.to_yaml)
103
+ when 'text'
104
+ content = []
105
+ all_links.each do |file, links|
106
+ content << "File: #{file}"
107
+ links.each do |link|
108
+ content << " Line #{link[:line]} (#{link[:type]}): #{link[:href]}"
109
+ content << " Suggested: #{link[:suggested_helper]}" if link[:suggested_helper]
110
+ end
111
+ content << ''
112
+ end
113
+ File.write(output_path, content.join("\n"))
114
+ end
115
+ end
116
+
117
+ def display_links(all_links)
118
+ all_links.each do |file, links|
119
+ puts "\n#{file}:"
120
+ links.each do |link|
121
+ puts " Line #{link[:line]} (#{link[:type]}): #{link[:href]}"
122
+ puts " → Suggested: #{link[:suggested_helper]}" if link[:suggested_helper]
123
+ end
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def is_hardcoded_link?(href)
130
+ # It's hardcoded if it's not a Rails helper or ERB expression
131
+ return false if href.include?('_path') || href.include?('_url')
132
+ return false if href.include?('<%') || href.include?('#{')
133
+ return false if href.start_with?('@') || href.start_with?(':')
134
+
135
+ # It's hardcoded if it's a static file or path
136
+ href.match?(/\.(html|htm|php|jsp|asp)$/) ||
137
+ href.match?(%r{^/\w+}) ||
138
+ href == '#' ||
139
+ href.match?(/^[a-z]+\.html$/i)
140
+ end
141
+
142
+ def suggest_rails_helper(href)
143
+ # Exact matches for common pages
144
+ common_mappings = {
145
+ 'index.html' => 'root_path',
146
+ 'home.html' => 'root_path',
147
+ 'login.html' => 'login_path',
148
+ 'signin.html' => 'login_path',
149
+ 'signup.html' => 'signup_path',
150
+ 'register.html' => 'new_user_registration_path',
151
+ 'about.html' => 'about_path',
152
+ 'contact.html' => 'contact_path',
153
+ 'privacy.html' => 'privacy_path',
154
+ 'terms.html' => 'terms_path',
155
+ 'dashboard.html' => 'dashboard_path',
156
+ 'profile.html' => 'profile_path',
157
+ 'settings.html' => 'settings_path'
158
+ }
159
+
160
+ # Check for exact match
161
+ clean_href = href.sub(%r{^/}, '')
162
+ return common_mappings[clean_href] if common_mappings[clean_href]
163
+
164
+ # Handle resource patterns
165
+ case href
166
+ when %r{^/?users?/(\d+|:id)}
167
+ 'user_path(@user)'
168
+ when %r{^/?users?$}, '/users'
169
+ 'users_path'
170
+ when %r{^/?posts?/(\d+|:id)}
171
+ 'post_path(@post)'
172
+ when %r{^/?posts?$}, '/posts'
173
+ 'posts_path'
174
+ when %r{^/?articles?/(\d+|:id)}
175
+ 'article_path(@article)'
176
+ when %r{^/?articles?$}, '/articles'
177
+ 'articles_path'
178
+ when %r{^/?products?/(\d+|:id)}
179
+ 'product_path(@product)'
180
+ when %r{^/?products?$}, '/products'
181
+ 'products_path'
182
+ when /^#/
183
+ nil # Anchor links don't need conversion
184
+ else
185
+ # Generic suggestion based on path
186
+ path = href.sub(%r{^/}, '').sub(/\.\w+$/, '')
187
+ return nil if path.empty?
188
+
189
+ # Convert path to Rails helper format
190
+ path_parts = path.split('/')
191
+ if path_parts.last =~ /^\d+$/
192
+ # Looks like a show action
193
+ resource = path_parts[-2]
194
+ "#{resource.singularize}_path(@#{resource.singularize})"
195
+ else
196
+ # Looks like an index or named route
197
+ "#{path.gsub("/", "_").gsub("-", "_")}_path"
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end