floatable-rails 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: d9d58deb57c87c0e5dff62513689456adfe23791b234088e485b729963f4a504
4
+ data.tar.gz: 285d4e4a29c62d97cf9cfe7cbc37c8131b2d56e0954ad7230dc98ce3ba051ea3
5
+ SHA512:
6
+ metadata.gz: 1198026452a8ff26ea48063bf4a7c9ba5b4886ebad8fb6dde9a5c1d5615f0a364f187e225936ba2303643d1cf08bca2fcb0632f152ff08efd288e1a329205720
7
+ data.tar.gz: 2acbbbb919b62d4b3134edd4147e603b5d68462ae2dd4ee400b65897538c600bd4406d7c85d0c1921e2a159b50aba204376c1f6b5b4c1a3a45fa053589a1b4f7
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Tobias Almstrand
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # Floatable::Rails
2
+
3
+ Rails integration for Floatable Toolbar. It instruments HTML output with
4
+ metadata and injects the Floatable script so your UI can be inspected and
5
+ annotated in the browser.
6
+
7
+ ## Usage
8
+
9
+ Add the helper to your layout if you want explicit control:
10
+
11
+ ```erb
12
+ <%= floatable_tag %>
13
+ ```
14
+
15
+ The middleware also injects the script automatically for HTML responses when
16
+ Floatable is enabled, so the helper is optional.
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem "floatable-rails"
24
+ ```
25
+
26
+ And then execute:
27
+
28
+ ```bash
29
+ $ bundle
30
+ ```
31
+
32
+ Or install it yourself as:
33
+
34
+ ```bash
35
+ $ gem install floatable-rails
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ Create an initializer (for example, `config/initializers/floatable.rb`):
41
+
42
+ ```ruby
43
+ Floatable::Rails.configure do |config|
44
+ # Enable Floatable per-request.
45
+ # Default: enabled in development, or when FLOATABLE_ENABLED=1
46
+ config.enabled = lambda do |request|
47
+ Rails.env.development? || ENV["FLOATABLE_ENABLED"] == "1"
48
+ end
49
+
50
+ # Metadata passed as data attributes on the script tag.
51
+ config.script_workspace = "your-floatable-workspace"
52
+ config.script_repository = "your-floatable-repo"
53
+ config.script_branch = "main"
54
+ config.script_agent_tools_mode = "remote|local"
55
+ end
56
+ ```
57
+
58
+ ## Release
59
+
60
+ 1. Bump the version in `lib/floatable/rails/version.rb`.
61
+ 2. Update `CHANGELOG.md`.
62
+ 3. Build the gem: `rake build` (outputs to `pkg/`).
63
+ 4. Push the gem: `rake release` or `gem push pkg/floatable-rails-<version>.gem`.
64
+
65
+ ## Contributing
66
+
67
+ Bug reports and pull requests are welcome.
68
+
69
+ ## License
70
+
71
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,47 @@
1
+ require "rails"
2
+ require "floatable/rails/helpers"
3
+ require "floatable/rails/tag_instrumentation"
4
+ require "floatable/rails/erb_line_instrumentation"
5
+ require "floatable/rails/slim_line_instrumentation"
6
+ require "floatable/rails/response_middleware"
7
+
8
+ module Floatable
9
+ module Rails
10
+ class Engine < ::Rails::Engine
11
+ isolate_namespace Floatable::Rails
12
+
13
+ initializer "floatable.rails.view_helpers" do
14
+ ActiveSupport.on_load(:action_view) do
15
+ include ::Floatable::Rails::Helpers
16
+ prepend ::Floatable::Rails::TagInstrumentation
17
+ end
18
+ end
19
+
20
+ initializer "floatable.rails.erb_line_instrumentation" do
21
+ ActiveSupport.on_load(:action_view) do
22
+ ::ActionView::Template::Handlers::ERB.prepend(::Floatable::Rails::ErbHandlerPatch)
23
+ ::ActionView::Template::Handlers::ERB.erb_implementation = ::Floatable::Rails::ErubiWithFloatableLines
24
+ end
25
+ end
26
+
27
+ initializer "floatable.rails.slim_line_instrumentation" do
28
+ ActiveSupport.on_load(:action_view) do
29
+ next unless defined?(::Slim::RailsTemplate)
30
+
31
+ ::Slim::RailsTemplate.prepend(::Floatable::Rails::SlimHandlerPatch)
32
+ end
33
+ end
34
+
35
+
36
+ initializer "floatable.rails.controller_helpers" do
37
+ ActiveSupport.on_load(:action_controller_base) do
38
+ helper ::Floatable::Rails::Helpers
39
+ end
40
+ end
41
+
42
+ initializer "floatable.rails.middleware" do |app|
43
+ app.middleware.use ::Floatable::Rails::ResponseMiddleware
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,164 @@
1
+ require "pathname"
2
+
3
+ module Floatable
4
+ module Rails
5
+ module ErbLineInstrumentation
6
+ LINE_MARKER_PREFIX = "FLOATABLE_LINE:".freeze
7
+ EXPR_LINE_MARKER_PREFIX = "FLOATABLE_EXPR_LINE:".freeze
8
+ end
9
+
10
+ module ErbHandlerPatch
11
+ def call(template, source)
12
+ source ||= template.source
13
+ template_source = source.b
14
+
15
+ erb = template_source.gsub(self.class::ENCODING_TAG, "")
16
+ encoding = $2
17
+
18
+ erb.force_encoding valid_encoding(source.dup, encoding)
19
+ erb.encode!
20
+ erb.chomp! if strip_trailing_newlines
21
+
22
+ options = {
23
+ escape: (self.class.escape_ignore_list.include? template.type),
24
+ trim: (self.class.erb_trim_mode == "-"),
25
+ filename: template.identifier
26
+ }
27
+
28
+ if ActionView::Base.annotate_rendered_view_with_filenames && template.format == :html
29
+ options[:preamble] = "@output_buffer.safe_append='<!-- BEGIN #{template.short_identifier}\n-->';"
30
+ options[:postamble] = "@output_buffer.safe_append='<!-- END #{template.short_identifier} -->';@output_buffer"
31
+ end
32
+
33
+ self.class.erb_implementation.new(erb, options).src
34
+ end
35
+ end
36
+
37
+ class ErubiWithFloatableLines < ::ActionView::Template::Handlers::ERB::Erubi
38
+ LINE_MARKER_PREFIX = ErbLineInstrumentation::LINE_MARKER_PREFIX
39
+ EXPR_LINE_MARKER_PREFIX = ErbLineInstrumentation::EXPR_LINE_MARKER_PREFIX
40
+
41
+ def initialize(input, properties = {})
42
+ @floatable_line = 1
43
+ @floatable_view_path = floatable_views_path(properties[:filename])
44
+ @floatable_in_views = @floatable_view_path.present?
45
+ if @floatable_in_views
46
+ properties = properties.dup
47
+ bufvar = properties[:bufvar] || "@output_buffer"
48
+ postamble = properties[:postamble] || bufvar
49
+ properties[:preamble] = "#{properties[:preamble]}@output_buffer.safe_append='<!-- BEGIN #{@floatable_view_path} -->' if Thread.current[:floatable_enabled];"
50
+ properties[:postamble] = "@output_buffer.safe_append='<!-- END #{@floatable_view_path} -->' if Thread.current[:floatable_enabled];#{postamble}"
51
+ end
52
+ super
53
+ end
54
+
55
+ private
56
+
57
+ def add_text(text)
58
+ return if text.empty?
59
+
60
+ if text == "\n"
61
+ @newline_pending += 1
62
+ return
63
+ end
64
+
65
+ full_text = ("\n" * @newline_pending) + text
66
+ with_buffer do
67
+ src << ".safe_append="
68
+ if @floatable_in_views
69
+ src << "(Thread.current[:floatable_enabled] ? "
70
+ src << floatable_text_literal(full_text)
71
+ src << " : "
72
+ src << plain_text_literal(full_text)
73
+ src << ")"
74
+ else
75
+ src << plain_text_literal(full_text)
76
+ end
77
+ end
78
+ @newline_pending = 0
79
+ @floatable_line += full_text.count("\n")
80
+ end
81
+
82
+ def add_expression(indicator, code)
83
+ flush_newline_if_pending(src)
84
+ if @floatable_in_views
85
+ with_buffer do
86
+ src << ".safe_append='#{expr_line_marker(@floatable_line)}' if Thread.current[:floatable_enabled];"
87
+ end
88
+ end
89
+ super
90
+ @floatable_line += code.count("\n")
91
+ end
92
+
93
+ def add_code(code)
94
+ flush_newline_if_pending(src)
95
+ super
96
+ @floatable_line += code.count("\n")
97
+ end
98
+
99
+ def flush_newline_if_pending(src)
100
+ return if @newline_pending <= 0
101
+
102
+ full_text = "\n" * @newline_pending
103
+ with_buffer do
104
+ src << ".safe_append="
105
+ if @floatable_in_views
106
+ src << "(Thread.current[:floatable_enabled] ? "
107
+ src << floatable_text_literal(full_text)
108
+ src << " : "
109
+ src << plain_text_literal(full_text)
110
+ src << ")"
111
+ else
112
+ src << plain_text_literal(full_text)
113
+ end
114
+ end
115
+ @floatable_line += @newline_pending
116
+ @newline_pending = 0
117
+ end
118
+
119
+ def line_marker(line)
120
+ "<!--#{LINE_MARKER_PREFIX}#{line}-->"
121
+ end
122
+
123
+ def expr_line_marker(line)
124
+ "<!--#{EXPR_LINE_MARKER_PREFIX}#{line}-->"
125
+ end
126
+
127
+ def floatable_text_literal(text)
128
+ marked = add_line_markers(text, @floatable_line)
129
+ plain_text_literal(marked)
130
+ end
131
+
132
+ def plain_text_literal(text)
133
+ "'" + text.gsub(/['\\]/, '\\\\\&') + @text_end
134
+ end
135
+
136
+ def add_line_markers(text, start_line)
137
+ lines = text.split("\n", -1)
138
+ out = +""
139
+ lines.each_with_index do |line_text, idx|
140
+ out << line_marker(start_line + idx) << line_text
141
+ out << "\n" if idx < lines.length - 1
142
+ end
143
+ out
144
+ end
145
+
146
+ def floatable_views_path(filename)
147
+ return nil if filename.nil?
148
+
149
+ str = filename.to_s
150
+ if str.include?("/app/views/") || str.start_with?("app/views/")
151
+ return str if str.start_with?("app/views/")
152
+
153
+ app_root = ::Rails.root.to_s
154
+ return str unless app_root.present?
155
+
156
+ return Pathname.new(str).relative_path_from(Pathname.new(app_root)).to_s
157
+ end
158
+ nil
159
+ rescue
160
+ nil
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,25 @@
1
+ module Floatable
2
+ module Rails
3
+ module Helpers
4
+ def floatable_enabled?
5
+ return false unless respond_to?(:request)
6
+
7
+ ::Floatable::Rails.enabled_for?(request)
8
+ end
9
+
10
+ def floatable_tag
11
+ return unless floatable_enabled?
12
+
13
+ src = ::Floatable::Rails.script_src
14
+ return if src.blank?
15
+
16
+ javascript_include_tag(
17
+ src,
18
+ type: "module",
19
+ defer: false,
20
+ data: ::Floatable::Rails.script_data_attributes
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,223 @@
1
+ require "nokogiri"
2
+ require "erb"
3
+ require "json"
4
+ require "floatable/rails/erb_line_instrumentation"
5
+
6
+ module Floatable
7
+ module Rails
8
+ class ResponseMiddleware
9
+ def initialize(app)
10
+ @app = app
11
+ end
12
+
13
+ def call(env)
14
+ enabled = floatable_enabled?(env)
15
+ Thread.current[:floatable_enabled] = enabled
16
+ status = headers = body = nil
17
+ begin
18
+ status, headers, body = @app.call(env)
19
+ ensure
20
+ Thread.current[:floatable_enabled] = nil
21
+ end
22
+
23
+ return [ status, headers, body ] unless html_response?(status, headers)
24
+ return [ status, headers, body ] unless enabled
25
+
26
+ raw = +""
27
+ body.each { |chunk| raw << chunk.to_s }
28
+ body.close if body.respond_to?(:close)
29
+
30
+ instrumented = instrument_html(raw, env)
31
+ final_html = inject_script(instrumented)
32
+
33
+ headers["Content-Length"] = final_html.bytesize.to_s if headers["Content-Length"]
34
+
35
+ [ status, headers, [ final_html ] ]
36
+ rescue => e
37
+ ::Rails.logger.error("[Floatable::Rails::ResponseMiddleware] failed: #{e.class} - #{e.message}")
38
+ [ status, headers, body ]
39
+ end
40
+
41
+ private
42
+
43
+ def html_response?(status, headers)
44
+ return false unless status == 200
45
+ ct = headers["Content-Type"] || headers["content-type"]
46
+ ct && ct.include?("text/html")
47
+ end
48
+
49
+ def floatable_enabled?(env)
50
+ req = ::Rack::Request.new(env)
51
+ ::Floatable::Rails.enabled_for?(req)
52
+ rescue => e
53
+ ::Rails.logger.error("[Floatable::Rails::ResponseMiddleware] enabled? failed: #{e.class} - #{e.message}")
54
+ false
55
+ end
56
+
57
+ def instrument_html(html, env)
58
+ return html if html.strip.empty?
59
+
60
+ doctype = html[/\A\s*<!DOCTYPE[^>]*>/i]
61
+ doc = Nokogiri::HTML.parse(html)
62
+ doc.css("*").each do |node|
63
+ name = node.name.to_s.downcase
64
+ next if %w[script style].include?(name)
65
+
66
+ # Don't override helper-provided metadata
67
+ has_path = node["data-floatable-path"].present?
68
+
69
+ unless node["data-floatable-content"].present?
70
+ data = {
71
+ name: node.name,
72
+ text: node.text.to_s.strip.presence,
73
+ class: node["class"].to_s.presence,
74
+ id: node["id"].to_s.presence
75
+ }.compact
76
+
77
+ if data.any?
78
+ encoded = ERB::Util.url_encode(JSON.generate(data)) rescue nil
79
+ node["data-floatable-content"] = encoded if encoded
80
+ end
81
+ end
82
+
83
+ next if has_path
84
+ end
85
+
86
+ apply_view_annotations(doc)
87
+ # Top → down: let descendants inherit path from ancestors when safe
88
+ propagate_file_metadata_down(doc)
89
+ # Ensure ids are present and consistent once paths are known
90
+ assign_floatable_ids(doc)
91
+
92
+ rendered = doc.to_html
93
+ return rendered if doctype.nil? || rendered.lstrip.start_with?("<!DOCTYPE")
94
+
95
+ "#{doctype}\n#{rendered}"
96
+ rescue => e
97
+ ::Rails.logger.debug("[Floatable::Rails] HTML instrumentation failed: #{e.class} - #{e.message}")
98
+ html
99
+ end
100
+
101
+ # Top → down: walk the tree and carry the closest ancestor's path
102
+ # down to all descendants that don't have their own.
103
+ def propagate_file_metadata_down(doc)
104
+ doc.children.each do |child|
105
+ next unless child.element?
106
+ propagate_file_metadata_down_from_node(child, current_path: nil)
107
+ end
108
+ rescue => e
109
+ ::Rails.logger.debug("[Floatable::Rails] propagate_file_metadata_down failed: #{e.class} - #{e.message}")
110
+ end
111
+
112
+ def propagate_file_metadata_down_from_node(node, current_path:)
113
+ # Update context with this node's path if it has it
114
+ node_path = node["data-floatable-path"].presence || current_path
115
+
116
+ # If this node doesn't have path but context does, inherit it
117
+ if node_path && node["data-floatable-path"].blank?
118
+ node["data-floatable-path"] = node_path
119
+ end
120
+
121
+ next_path = layout_path?(node_path) ? nil : node_path
122
+
123
+ node.element_children.each do |child|
124
+ propagate_file_metadata_down_from_node(child, current_path: next_path)
125
+ end
126
+ end
127
+
128
+ def inject_script(html)
129
+ src = ::Floatable::Rails.script_src
130
+ return html if src.blank?
131
+
132
+ if html.match?(/<script\b[^>]*\bsrc=["']#{Regexp.escape(src)}["'][^>]*>/)
133
+ return html
134
+ end
135
+
136
+ data_attrs = ::Floatable::Rails.script_data_attributes_html
137
+ snippet = %(<script type="module" src="#{ERB::Util.html_escape(src)}"#{data_attrs}></script>)
138
+
139
+ if html.include?("</body>")
140
+ html.sub("</body>", "#{snippet}\n</body>")
141
+ else
142
+ html + snippet
143
+ end
144
+ rescue => e
145
+ ::Rails.logger.error("[Floatable::Rails::ResponseMiddleware] inject_script failed: #{e.class} - #{e.message}")
146
+ html
147
+ end
148
+
149
+ def assign_floatable_ids(doc)
150
+ doc.css("*[data-floatable-path]").each do |node|
151
+ line = node["data-floatable-line"].presence
152
+ next unless line
153
+
154
+ desired = "#{node["data-floatable-path"]}:#{line}"
155
+ node["data-floatable-id"] = desired if node["data-floatable-id"] != desired
156
+ end
157
+ rescue => e
158
+ ::Rails.logger.debug("[Floatable::Rails] assign_floatable_ids failed: #{e.class} - #{e.message}")
159
+ end
160
+
161
+ def layout_path?(path)
162
+ str = path.to_s
163
+ str.include?("/app/views/layouts/") || str.start_with?("app/views/layouts/")
164
+ end
165
+
166
+ def app_views_path?(path)
167
+ str = path.to_s
168
+ str.include?("/app/views/") || str.start_with?("app/views/")
169
+ end
170
+
171
+ def apply_view_annotations(doc)
172
+ view_stack = []
173
+ line_prefix = ::Floatable::Rails::ErbLineInstrumentation::LINE_MARKER_PREFIX
174
+ expr_line_prefix = ::Floatable::Rails::ErbLineInstrumentation::EXPR_LINE_MARKER_PREFIX
175
+ line_comment_re = /\A#{Regexp.escape(line_prefix)}\d+\z/
176
+ expr_comment_re = /\A#{Regexp.escape(expr_line_prefix)}\d+\z/
177
+ doc.traverse do |node|
178
+ if node.comment?
179
+ content = node.content.to_s.strip
180
+ if content.start_with?("BEGIN ")
181
+ path = content.delete_prefix("BEGIN ").strip
182
+ view_stack << path if app_views_path?(path)
183
+ next
184
+ end
185
+
186
+ if content.start_with?("END ")
187
+ path = content.delete_prefix("END ").strip
188
+ view_stack.pop if view_stack.last == path
189
+ next
190
+ end
191
+
192
+ next
193
+ end
194
+
195
+ next unless node.element?
196
+
197
+ current_path = view_stack.last
198
+ node["data-floatable-path"] ||= current_path if current_path.present?
199
+
200
+ line = floatable_line_from_previous_siblings(node, line_prefix, expr_line_prefix, line_comment_re, expr_comment_re)
201
+ node["data-floatable-line"] = line if line.to_i > 0 && current_path.present?
202
+ end
203
+
204
+ doc.xpath("//comment()").remove
205
+ rescue => e
206
+ ::Rails.logger.debug("[Floatable::Rails] apply_view_annotations failed: #{e.class} - #{e.message}")
207
+ end
208
+
209
+ def floatable_line_from_previous_siblings(node, line_prefix, expr_line_prefix, line_comment_re, expr_comment_re)
210
+ prev = node.previous_sibling
211
+ while prev
212
+ if prev.comment?
213
+ content = prev.content.to_s.strip
214
+ return content.delete_prefix(expr_line_prefix).to_i if content.match?(expr_comment_re)
215
+ return content.delete_prefix(line_prefix).to_i if content.match?(line_comment_re)
216
+ end
217
+ prev = prev.previous_sibling
218
+ end
219
+ nil
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,52 @@
1
+ require "pathname"
2
+
3
+ module Floatable
4
+ module Rails
5
+ module SlimLineInstrumenter
6
+ def self.instrument(source, view_path)
7
+ lines = source.to_s.split("\n", -1)
8
+ out = []
9
+ out << "- @output_buffer.safe_concat('<!-- BEGIN #{view_path} -->') if Thread.current[:floatable_enabled] && defined?(@output_buffer)"
10
+
11
+ lines.each_with_index do |line, idx|
12
+ indent = line[/\A[ \t]*/] || ""
13
+ out << "#{indent}- @output_buffer.safe_concat('<!--#{ErbLineInstrumentation::LINE_MARKER_PREFIX}#{idx + 1}-->') if Thread.current[:floatable_enabled] && defined?(@output_buffer)"
14
+ out << line
15
+ end
16
+
17
+ out << "- @output_buffer.safe_concat('<!-- END #{view_path} -->') if Thread.current[:floatable_enabled] && defined?(@output_buffer)"
18
+ out.join("\n")
19
+ end
20
+ end
21
+
22
+ module SlimHandlerPatch
23
+ def call(template, source = nil)
24
+ return super unless template.format == :html
25
+
26
+ view_path = floatable_views_path(template.identifier)
27
+ return super if view_path.nil?
28
+
29
+ instrumented = SlimLineInstrumenter.instrument(source || template.source, view_path)
30
+ super(template, instrumented)
31
+ end
32
+
33
+ private
34
+
35
+ def floatable_views_path(filename)
36
+ return nil if filename.nil?
37
+
38
+ str = filename.to_s
39
+ return nil unless str.include?("/app/views/") || str.start_with?("app/views/")
40
+
41
+ return str if str.start_with?("app/views/")
42
+
43
+ app_root = ::Rails.root.to_s
44
+ return str unless app_root.present?
45
+
46
+ Pathname.new(str).relative_path_from(Pathname.new(app_root)).to_s
47
+ rescue
48
+ nil
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,209 @@
1
+ require "pathname"
2
+ require "json"
3
+ require "erb"
4
+
5
+ module Floatable
6
+ module Rails
7
+ module TagInstrumentation
8
+ def content_tag(name, content_or_options_with_block = nil, options = nil, escape = true, &block)
9
+ if floatable_enabled_for_view?
10
+ options = ensure_options_hash(content_or_options_with_block, options)
11
+ file, line = floatable_callsite
12
+
13
+ if file
14
+ options[:"data-floatable-path"] ||= floatable_relative_path(file)
15
+ if line
16
+ options[:"data-floatable-line"] ||= line
17
+ options[:"data-floatable-id"] ||= "#{options[:"data-floatable-path"]}:#{options[:"data-floatable-line"]}"
18
+ end
19
+ end
20
+
21
+ options[:"data-floatable-content"] ||= floatable_content_attr(
22
+ tag_name: name,
23
+ options: options,
24
+ content_text: block_given? ? nil : content_or_options_with_block
25
+ )
26
+
27
+ # Re-shape args for super if we mutated them
28
+ if content_or_options_with_block.is_a?(Hash) && options
29
+ content_or_options_with_block = options
30
+ options = nil
31
+ end
32
+ end
33
+
34
+ super
35
+ end
36
+
37
+ def tag(name = nil, options = nil, open = false, escape = true)
38
+ if floatable_enabled_for_view? && name
39
+ options = (options || {}).dup
40
+ file, line = floatable_callsite
41
+
42
+ if file
43
+ options[:"data-floatable-path"] ||= floatable_relative_path(file)
44
+ if line
45
+ options[:"data-floatable-line"] ||= line
46
+ options[:"data-floatable-id"] ||= "#{options[:"data-floatable-path"]}:#{options[:"data-floatable-line"]}"
47
+ end
48
+ end
49
+
50
+ options[:"data-floatable-content"] ||= floatable_content_attr(
51
+ tag_name: name,
52
+ options: options,
53
+ content_text: nil
54
+ )
55
+
56
+ return super(name, options, open, escape)
57
+ end
58
+
59
+ super
60
+ end
61
+
62
+ private
63
+
64
+ def floatable_enabled_for_view?
65
+ return false unless respond_to?(:request)
66
+
67
+ ::Floatable::Rails.enabled_for?(request)
68
+ rescue => e
69
+ ::Rails.logger.error("[Floatable::Rails::TagInstrumentation] enabled? failed: #{e.class} - #{e.message}")
70
+ false
71
+ end
72
+
73
+ def floatable_content_attr(tag_name:, options:, content_text:)
74
+ data = {
75
+ name: tag_name.to_s,
76
+ text: content_text.to_s.presence,
77
+ class: options[:class].to_s.presence,
78
+ id: options[:id].to_s.presence
79
+ }.compact
80
+
81
+ return if data.empty?
82
+
83
+ ERB::Util.url_encode(JSON.generate(data))
84
+ rescue
85
+ nil
86
+ end
87
+
88
+ def ensure_options_hash(content_or_options_with_block, options)
89
+ if content_or_options_with_block.is_a?(Hash) && options.nil?
90
+ content_or_options_with_block
91
+ else
92
+ options || {}
93
+ end
94
+ end
95
+
96
+ # Callsite detection
97
+ #
98
+ # We want the *template* or app file (e.g. app/views/posts/index.html.erb),
99
+ # not ActionView's gem helpers.
100
+ #
101
+ # Strategy:
102
+ # - Walk the call stack.
103
+ # - Pick the first frame whose path starts with Rails.root
104
+ # (so we ignore anything under ~/.rbenv, /gems/, etc).
105
+ # - Prefer files under app/views, but accept any app file as fallback.
106
+ #
107
+ def floatable_callsite
108
+ app_root = ::Rails.root.to_s
109
+ return [ nil, nil ] if app_root.blank?
110
+
111
+ locations = caller_locations(2, 200) || []
112
+
113
+ template_path = floatable_current_template_path
114
+ if template_path
115
+ loc = locations.find { |loc_item| loc_item.path.to_s == template_path }
116
+ return [ template_path, loc.lineno ] if loc
117
+ end
118
+
119
+ # First pass: prefer app/views templates, excluding layouts when possible.
120
+ view_locs = locations.select do |loc|
121
+ path = loc.path.to_s
122
+ path.start_with?(app_root) && path.include?("/app/views/") && path.end_with?(".erb")
123
+ end
124
+
125
+ unless view_locs.empty?
126
+ non_layout = view_locs.reverse.find { |loc| !loc.path.to_s.include?("/app/views/layouts/") }
127
+ chosen = non_layout || view_locs.last
128
+ return [ chosen.path, chosen.lineno ]
129
+ end
130
+
131
+ # Second pass: any file under Rails.root (app/models, app/controllers, etc)
132
+ app_loc = locations.find do |loc|
133
+ path = loc.path.to_s
134
+ path.start_with?(app_root)
135
+ end
136
+
137
+ if app_loc
138
+ return [ app_loc.path, app_loc.lineno ]
139
+ end
140
+
141
+ # If nothing inside the app, we give up and return nil
142
+ [ nil, nil ]
143
+ rescue => e
144
+ ::Rails.logger.debug("[Floatable::Rails::TagInstrumentation] floatable_callsite failed: #{e.class} - #{e.message}")
145
+ [ nil, nil ]
146
+ end
147
+
148
+ def floatable_current_template_path
149
+ return nil unless respond_to?(:lookup_context)
150
+
151
+ current_template = floatable_current_template_identifier
152
+ return current_template if current_template.present?
153
+
154
+ vpath = floatable_virtual_path
155
+ return nil if vpath.empty?
156
+
157
+ templates = lookup_context.find_all(vpath, [], true)
158
+ template = templates.first || lookup_context.find_all(vpath, [], false).first
159
+ identifier = template&.identifier.to_s
160
+ identifier.presence
161
+ rescue
162
+ nil
163
+ end
164
+
165
+ def floatable_current_template_identifier
166
+ return nil unless instance_variable_defined?(:@current_template)
167
+
168
+ template = instance_variable_get(:@current_template)
169
+ identifier = template&.identifier.to_s
170
+ identifier.presence
171
+ rescue
172
+ nil
173
+ end
174
+
175
+ def floatable_virtual_path
176
+ vpath = nil
177
+ vpath = virtual_path.to_s if respond_to?(:virtual_path)
178
+
179
+ if vpath.to_s.empty?
180
+ vpath = instance_variable_get(:@virtual_path).to_s if instance_variable_defined?(:@virtual_path)
181
+ end
182
+
183
+ if vpath.to_s.empty?
184
+ vpath = instance_variable_get(:@_virtual_path).to_s if instance_variable_defined?(:@_virtual_path)
185
+ end
186
+
187
+ vpath.to_s
188
+ rescue
189
+ ""
190
+ end
191
+
192
+ def floatable_relative_path(file)
193
+ return nil if file.blank?
194
+
195
+ app_root = ::Rails.root.to_s
196
+ return file unless app_root.present?
197
+
198
+ # Only record paths inside the app root; ignore gems/rbenv/etc.
199
+ unless file.start_with?(app_root)
200
+ return nil
201
+ end
202
+
203
+ Pathname.new(file).relative_path_from(Pathname.new(app_root)).to_s
204
+ rescue
205
+ file
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,5 @@
1
+ module Floatable
2
+ module Rails
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
@@ -0,0 +1,90 @@
1
+ require "erb"
2
+ require "floatable/rails/version"
3
+ require "floatable/rails/engine"
4
+
5
+ # lib/floatable/rails.rb
6
+ module Floatable
7
+ module Rails
8
+ # Avoid namespace resolution issues for apps that define module Floatable.
9
+ # In that case, `Rails::Application` resolves to `Floatable::Rails::Application`,
10
+ # so provide a compatible alias.
11
+ Application = ::Rails::Application if defined?(::Rails::Application) && !const_defined?(:Application)
12
+
13
+ class Configuration
14
+ # Proc: ->(request) { true/false }
15
+ attr_accessor :enabled
16
+ # URL for the Floatable toolbar script
17
+ attr_accessor :script_src
18
+ # Metadata
19
+ attr_accessor :script_workspace
20
+ attr_accessor :script_repository
21
+ attr_accessor :script_repository_name
22
+ attr_accessor :script_branch
23
+ attr_accessor :script_agent_tools_mode
24
+
25
+ def initialize
26
+ # Default: enabled in development or when FLOATABLE_ENABLED=1
27
+ @enabled = lambda do |_request|
28
+ ::Rails.env.development? || ENV["FLOATABLE_ENABLED"] == "1"
29
+ end
30
+
31
+ # Default script for dev; override in host app
32
+ @script_src = "https://assets.floatable.dev/js/toolbar.js"
33
+
34
+ @script_workspace = nil
35
+ @script_repository = nil
36
+ @script_repository_name = nil
37
+ @script_branch = nil
38
+ @script_agent_tools_mode = nil
39
+ end
40
+ end
41
+
42
+ class << self
43
+ def config
44
+ @config ||= Configuration.new
45
+ end
46
+
47
+ def configure
48
+ yield(config)
49
+ end
50
+
51
+ # Central predicate for "is Floatable active for this request?"
52
+ def enabled_for?(request)
53
+ predicate = config.enabled
54
+ return false unless predicate.respond_to?(:call)
55
+
56
+ !!predicate.call(request)
57
+ rescue => e
58
+ ::Rails.logger.error("[Floatable::Rails] enabled_for? failed: #{e.class} - #{e.message}")
59
+ false
60
+ end
61
+
62
+ def script_src
63
+ config.script_src
64
+ end
65
+
66
+ def script_data
67
+ {
68
+ workspace: config.script_workspace,
69
+ repository: config.script_repository,
70
+ repository_name: config.script_repository_name,
71
+ branch: config.script_branch,
72
+ agent_tools_mode: config.script_agent_tools_mode
73
+ }.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
74
+ end
75
+
76
+ def script_data_attributes
77
+ script_data.transform_keys { |key| :"floatable_#{key}" }
78
+ end
79
+
80
+ def script_data_attributes_html
81
+ data = script_data
82
+ return "" if data.empty?
83
+
84
+ data.map do |key, value|
85
+ %( data-floatable-#{key.to_s.tr("_", "-")}="#{ERB::Util.html_escape(value.to_s)}")
86
+ end.join
87
+ end
88
+ end
89
+ end
90
+ end
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: floatable-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tobias Almstrand
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: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.1.1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.1.1
26
+ description: Instruments HTML output with metadata and injects the Floatable script
27
+ so your UI can be inspected and annotated in the browser.
28
+ email:
29
+ - tobias@almstrand.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - MIT-LICENSE
36
+ - README.md
37
+ - Rakefile
38
+ - lib/floatable/rails.rb
39
+ - lib/floatable/rails/engine.rb
40
+ - lib/floatable/rails/erb_line_instrumentation.rb
41
+ - lib/floatable/rails/helpers.rb
42
+ - lib/floatable/rails/response_middleware.rb
43
+ - lib/floatable/rails/slim_line_instrumentation.rb
44
+ - lib/floatable/rails/tag_instrumentation.rb
45
+ - lib/floatable/rails/version.rb
46
+ homepage: https://floatable.dev
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://floatable.dev
51
+ source_code_uri: https://floatable.dev
52
+ changelog_uri: https://floatable.dev/blob/main/CHANGELOG.md
53
+ rubygems_mfa_required: 'true'
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.6.9
69
+ specification_version: 4
70
+ summary: Rails integration for the Floatable Toolbar.
71
+ test_files: []