haml2html 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6e1c4e54e801d2d2bf84e9258139c58af9f8096b06b321af619e19483eaf592a
4
+ data.tar.gz: b7df8ef491439377c23adc35a05ae9845fd25c002056cdacf7a1341b69001604
5
+ SHA512:
6
+ metadata.gz: 95e12f9a8e0fd412dd71312bfd19db9f320440d21e15be51feefc6d52fa77062a385e7fa28e57cdaef2509d5f1dcf0250b240f2bfcc93840062ad1b7257708dd
7
+ data.tar.gz: 8d791b7b6a3bfb126ba4c46a7488ebd5c006382a78f73ffe265839ceb3c947d94498068493781f938fc234d1044444f35abb3e137b7bf293d9b6c2503954fd9f
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial Haml-to-ERB converter.
6
+ - CLI for stdin/stdout and explicit input/output files.
7
+ - Rails render-equivalence test coverage for common Haml constructs.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kyle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # haml2html
2
+
3
+ `haml2html` converts Haml templates to Rails ERB templates. It is built for Rails app migrations where rendered HTML equivalence matters more than preserving original source formatting.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ gem install haml2html
9
+ ```
10
+
11
+ Or in a Gemfile:
12
+
13
+ ```ruby
14
+ gem "haml2html"
15
+ ```
16
+
17
+ ## CLI
18
+
19
+ ```sh
20
+ haml2html app/views/posts/show.html.haml app/views/posts/show.html.erb
21
+ haml2html --stdin < app/views/posts/show.html.haml
22
+ ```
23
+
24
+ The CLI writes to stdout unless an output path is provided. Unsupported syntax is reported with file and line diagnostics and exits nonzero.
25
+
26
+ ## Ruby API
27
+
28
+ ```ruby
29
+ require "haml2html"
30
+
31
+ erb = Haml2html::Converter.new("%p= post.title\n", filename: "show.html.haml").render
32
+ ```
33
+
34
+ ## Supported
35
+
36
+ - Haml tags, nesting, static attributes, text, interpolation.
37
+ - Ruby output and control flow: `=`, `!=`, `- if`, `- each do`, and similar blocks.
38
+ - Public comments and silent comments.
39
+ - `:plain`, `:escaped`, `:javascript`, `:css`, and `:erb` filters.
40
+ - Dynamic Haml attributes and object references through `Haml::AttributeBuilder`.
41
+
42
+ Generated ERB may call `Haml::AttributeBuilder` for dynamic attributes and object references. Keep `haml` available at runtime until those converted templates are simplified.
43
+
44
+ ## Limitations
45
+
46
+ This is a migration tool, not a full source-preserving formatter. Output whitespace and quote style may differ from Haml output, while rendered HTML should remain equivalent for supported constructs. Unsupported filters or nodes fail with diagnostics instead of emitting known-wrong ERB.
47
+
48
+ Batch directory conversion is planned after the single-file converter is stable.
49
+
50
+ ## Publishing Checklist
51
+
52
+ 1. Verify gemspec URLs match the final repository URL.
53
+ 2. Run `rake test`.
54
+ 3. Run `gem build haml2html.gemspec --strict`.
55
+ 4. Inspect package contents with `gem spec haml2html-0.1.0.gem files`.
56
+ 5. Publish with RubyGems MFA enabled.
data/bin/haml2html ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "optparse"
7
+ require "haml2html"
8
+
9
+ options = {}
10
+
11
+ parser = OptionParser.new do |opts|
12
+ opts.banner = "Usage: haml2html [options] [INPUT] [OUTPUT]"
13
+ opts.on("-s", "--stdin", "Read input from standard input") { options[:stdin] = true }
14
+ opts.on("-h", "--help", "Show this message") do
15
+ puts opts
16
+ exit
17
+ end
18
+ opts.on("-v", "--version", "Print version") do
19
+ puts Haml2html::VERSION
20
+ exit
21
+ end
22
+ end
23
+
24
+ parser.parse!
25
+
26
+ begin
27
+ input_path, output_path = ARGV
28
+
29
+ if options[:stdin]
30
+ source = STDIN.read
31
+ elsif input_path
32
+ source = File.read(input_path)
33
+ else
34
+ warn parser
35
+ exit 1
36
+ end
37
+
38
+ result = Haml2html::Converter.new(source, filename: input_path).render
39
+
40
+ if output_path
41
+ File.write(output_path, result)
42
+ else
43
+ print result
44
+ end
45
+ rescue Haml2html::ConversionError => error
46
+ warn error.message
47
+ exit 2
48
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "haml"
5
+
6
+ require_relative "diagnostic"
7
+
8
+ module Haml2html
9
+ class Converter
10
+ VOID_TAGS = %w[area base br col embed hr img input link meta param source track wbr].freeze
11
+ SUPPORTED_FILTERS = %w[plain escaped javascript css erb].freeze
12
+
13
+ attr_reader :diagnostics
14
+
15
+ def initialize(template, options = {})
16
+ @template = template.respond_to?(:read) ? template.read : template.to_s
17
+ @lines = @template.lines
18
+ @filename = options[:filename]
19
+ @diagnostics = []
20
+ end
21
+
22
+ def render
23
+ ast = Haml::Parser.new(filename: @filename).call(@template)
24
+ output = emit_children(ast.children, 0)
25
+ raise ConversionError, diagnostics if diagnostics.any?
26
+
27
+ output
28
+ end
29
+
30
+ private
31
+
32
+ def emit_children(children, indent)
33
+ children.map { |child| emit_node(child, indent) }.join
34
+ end
35
+
36
+ def emit_node(node, indent)
37
+ case node.type
38
+ when :root
39
+ emit_children(node.children, indent)
40
+ when :plain
41
+ emit_plain(node, indent)
42
+ when :tag
43
+ emit_tag(node, indent)
44
+ when :script
45
+ emit_script(node, indent)
46
+ when :silent_script
47
+ emit_silent_script(node, indent)
48
+ when :haml_comment
49
+ "#{spaces(indent)}<%# #{node.value[:text].to_s.strip} %>\n"
50
+ when :comment
51
+ emit_comment(node, indent)
52
+ when :doctype
53
+ emit_doctype(node, indent)
54
+ when :filter
55
+ emit_filter(node, indent)
56
+ else
57
+ unsupported(node, node.type, "unsupported Haml node")
58
+ ""
59
+ end
60
+ end
61
+
62
+ def emit_plain(node, indent)
63
+ text = interpolate_text(node.value[:text].to_s)
64
+ "#{spaces(indent)}#{text}\n"
65
+ end
66
+
67
+ def emit_tag(node, indent)
68
+ value = node.value
69
+ name = value.fetch(:name)
70
+ attrs = emit_attributes(node)
71
+ open = "#{spaces(indent)}<#{name}#{attrs}>"
72
+ close = "</#{name}>"
73
+
74
+ if value[:self_closing] || (VOID_TAGS.include?(name) && node.children.empty? && blank?(value[:value]))
75
+ return "#{spaces(indent)}<#{name}#{attrs}>\n"
76
+ end
77
+
78
+ inline = inline_tag_value(node)
79
+ if node.children.empty? && !blank?(inline)
80
+ return "#{open}#{inline}#{close}\n"
81
+ end
82
+
83
+ "#{open}\n#{emit_children(node.children, indent + 1)}#{spaces(indent)}#{close}\n"
84
+ end
85
+
86
+ def inline_tag_value(node)
87
+ value = node.value
88
+ return nil if value[:parse].nil? && blank?(value[:value])
89
+
90
+ if value[:parse]
91
+ marker = raw_script_line?(node) ? "<%==" : "<%="
92
+ raw = "#{marker} #{value[:value].to_s.strip} %>"
93
+ value[:preserve_script] ? raw : raw
94
+ else
95
+ interpolate_text(value[:value].to_s)
96
+ end
97
+ end
98
+
99
+ def emit_attributes(node)
100
+ value = node.value
101
+ return runtime_attributes(value) if runtime_attributes?(value)
102
+
103
+ attrs = value[:attributes].sort.map do |name, attr_value|
104
+ %( #{name}="#{escape_attr(interpolate_text(attr_value.to_s))}")
105
+ end
106
+ attrs.join
107
+ end
108
+
109
+ def emit_script(node, indent)
110
+ code = node.value[:text].to_s.strip
111
+ if interpolated_string_literal?(code)
112
+ return "#{spaces(indent)}#{interpolate_text(unquote_string_literal(code))}\n"
113
+ end
114
+
115
+ marker = raw_script_line?(node) ? "<%==" : "<%="
116
+ "#{spaces(indent)}#{marker} #{code} %>\n"
117
+ end
118
+
119
+ def emit_silent_script(node, indent)
120
+ code = node.value[:text].to_s.strip
121
+ output = +"#{spaces(indent)}<% #{code} %>\n"
122
+ output << emit_children(node.children, indent + 1)
123
+ output << "#{spaces(indent)}<% end %>\n" if closes_with_end?(node)
124
+ output
125
+ end
126
+
127
+ def emit_comment(node, indent)
128
+ conditional = node.value[:conditional]
129
+ text = node.value[:text].to_s
130
+ if conditional
131
+ "#{spaces(indent)}<!--[#{conditional}]>#{text}<![endif]-->\n"
132
+ elsif text.include?("\n")
133
+ "#{spaces(indent)}<!--\n#{indent_block(text, indent + 1)}#{spaces(indent)}-->\n"
134
+ else
135
+ "#{spaces(indent)}<!-- #{text.strip} -->\n"
136
+ end
137
+ end
138
+
139
+ def emit_doctype(node, indent)
140
+ version = node.value[:version]
141
+ type = node.value[:type].to_s
142
+ html5 = version.nil? && type.empty?
143
+ text = html5 ? "<!DOCTYPE html>" : "<!DOCTYPE html>"
144
+ "#{spaces(indent)}#{text}\n"
145
+ end
146
+
147
+ def emit_filter(node, indent)
148
+ name = node.value[:name].to_s
149
+ text = node.value[:text].to_s
150
+
151
+ unless SUPPORTED_FILTERS.include?(name)
152
+ unsupported(node, "filter :#{name}", "unsupported filter")
153
+ return ""
154
+ end
155
+
156
+ case name
157
+ when "plain"
158
+ indent_block(text, indent)
159
+ when "escaped"
160
+ indent_block(CGI.escapeHTML(text), indent)
161
+ when "javascript"
162
+ "#{spaces(indent)}<script>\n#{indent_block(text, indent + 1)}#{spaces(indent)}</script>\n"
163
+ when "css"
164
+ "#{spaces(indent)}<style>\n#{indent_block(text, indent + 1)}#{spaces(indent)}</style>\n"
165
+ when "erb"
166
+ indent_block(text, indent)
167
+ end
168
+ end
169
+
170
+ def runtime_attributes?(value)
171
+ object_ref = value[:object_ref]
172
+ return true unless object_ref.nil? || object_ref == :nil
173
+
174
+ dynamic = value[:dynamic_attributes]
175
+ return false unless dynamic
176
+
177
+ dynamic_literals(dynamic).any?
178
+ end
179
+
180
+ def runtime_attributes(value)
181
+ args = ["true", '"\\"".freeze', ":html", object_ref_literal(value[:object_ref])]
182
+ args << value[:attributes].inspect unless value[:attributes].empty?
183
+ args.concat(dynamic_literals(value[:dynamic_attributes]))
184
+
185
+ %(<%== (require "haml"; ::Haml::AttributeBuilder.build(#{args.join(", ")})) %>)
186
+ end
187
+
188
+ def object_ref_literal(object_ref)
189
+ return "nil" if object_ref.nil? || object_ref == :nil
190
+
191
+ object_ref
192
+ end
193
+
194
+ def dynamic_literals(dynamic)
195
+ return [] unless dynamic
196
+
197
+ [dynamic.new, stripped_old_dynamic_literal(dynamic.old)].compact
198
+ end
199
+
200
+ def stripped_old_dynamic_literal(old)
201
+ return nil if old.nil?
202
+
203
+ old.dup.sub(/\A{/, "").sub(/}\z/m, "")
204
+ end
205
+
206
+ def diagnose_object_ref(node, value)
207
+ object_ref = value[:object_ref]
208
+ return if object_ref.nil? || object_ref == :nil
209
+
210
+ unsupported(node, "object reference", "object references cannot be faithfully converted to inline attrs")
211
+ end
212
+
213
+ def diagnose_dynamic_attributes(node, value)
214
+ dynamic = value[:dynamic_attributes]
215
+ return unless dynamic
216
+
217
+ if dynamic.respond_to?(:new) && dynamic.new
218
+ unsupported(node, "dynamic attributes", "new-style dynamic attributes are not supported")
219
+ end
220
+
221
+ if dynamic.respond_to?(:old) && dynamic.old
222
+ unsupported(node, "dynamic attributes", "old-style dynamic attributes are not supported")
223
+ end
224
+ end
225
+
226
+ def closes_with_end?(node)
227
+ keyword = node.value[:keyword].to_s
228
+ return true if %w[if unless case begin for while until].include?(keyword)
229
+
230
+ node.value[:text].to_s.match?(/\bdo(\s*\|.*\|)?\s*\z/)
231
+ end
232
+
233
+ def interpolate_text(text)
234
+ text.gsub(/#\{([^{}]+)\}/, '<%= \1 %>')
235
+ end
236
+
237
+ def interpolated_string_literal?(code)
238
+ code.match?(/\A"(?:[^"\\]|\\.|#\{[^{}]+\})*"\z/)
239
+ end
240
+
241
+ def unquote_string_literal(code)
242
+ code[1...-1].gsub('\"', '"').gsub('\n', "\n")
243
+ end
244
+
245
+ def escape_attr(value)
246
+ value.gsub("&", "&amp;").gsub('"', "&quot;")
247
+ end
248
+
249
+ def indent_block(text, indent)
250
+ text.to_s.each_line.map { |line| "#{spaces(indent)}#{line}" }.join
251
+ end
252
+
253
+ def unsupported(node, feature, message)
254
+ diagnostics << Diagnostic.new(filename: @filename, line: node.line, feature: feature.to_s, message: message)
255
+ end
256
+
257
+ def raw_script_line?(node)
258
+ line = @lines.fetch(node.line.to_i - 1, "")
259
+ line.lstrip.start_with?("!=") || line.include?("!=")
260
+ end
261
+
262
+ def blank?(value)
263
+ value.nil? || value.to_s.empty?
264
+ end
265
+
266
+ def spaces(indent)
267
+ " " * indent
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haml2html
4
+ Diagnostic = Struct.new(:filename, :line, :feature, :message, keyword_init: true) do
5
+ def to_s
6
+ location = [filename, line].compact.join(":")
7
+ location = "haml" if location.empty?
8
+ "#{location}: #{feature}: #{message}"
9
+ end
10
+ end
11
+
12
+ class ConversionError < StandardError
13
+ attr_reader :diagnostics
14
+
15
+ def initialize(diagnostics)
16
+ @diagnostics = diagnostics
17
+ super(diagnostics.map(&:to_s).join("\n"))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haml2html
4
+ VERSION = "0.1.0"
5
+ end
data/lib/haml2html.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "haml2html/converter"
4
+ require_relative "haml2html/diagnostic"
5
+ require_relative "haml2html/version"
6
+
7
+ module Haml2html
8
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: haml2html
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kyle
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-05-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: haml
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '6.3'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '6.3'
27
+ - !ruby/object:Gem::Dependency
28
+ name: actionview
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ - - "<"
35
+ - !ruby/object:Gem::Version
36
+ version: '9.0'
37
+ type: :development
38
+ prerelease: false
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '7.0'
44
+ - - "<"
45
+ - !ruby/object:Gem::Version
46
+ version: '9.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: minitest
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '5.0'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rake
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '13.0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '13.0'
75
+ description: A Haml-to-ERB migration tool for Rails templates.
76
+ email:
77
+ - kyle.duncan.dev@gmail.com
78
+ executables:
79
+ - haml2html
80
+ extensions: []
81
+ extra_rdoc_files: []
82
+ files:
83
+ - CHANGELOG.md
84
+ - LICENSE
85
+ - README.md
86
+ - bin/haml2html
87
+ - lib/haml2html.rb
88
+ - lib/haml2html/converter.rb
89
+ - lib/haml2html/diagnostic.rb
90
+ - lib/haml2html/version.rb
91
+ homepage: https://github.com/kyle/haml2html
92
+ licenses:
93
+ - MIT
94
+ metadata:
95
+ homepage_uri: https://github.com/kyle/haml2html
96
+ source_code_uri: https://github.com/kyle/haml2html/tree/main
97
+ changelog_uri: https://github.com/kyle/haml2html/blob/main/CHANGELOG.md
98
+ post_install_message:
99
+ rdoc_options: []
100
+ require_paths:
101
+ - lib
102
+ required_ruby_version: !ruby/object:Gem::Requirement
103
+ requirements:
104
+ - - ">="
105
+ - !ruby/object:Gem::Version
106
+ version: '3.1'
107
+ required_rubygems_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ requirements: []
113
+ rubygems_version: 3.5.9
114
+ signing_key:
115
+ specification_version: 4
116
+ summary: Convert Haml templates to Rails ERB.
117
+ test_files: []