view_component_devtools 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: 21babb25c8660808d7564914dd2105566e39e9954a197643e7862815ea867583
4
+ data.tar.gz: c64e1042bef475216ff30b64d7fc8f5ac16d308524bb916d2460ef3a9d3e75a9
5
+ SHA512:
6
+ metadata.gz: f88cbecd121cc209eb6dd9e2a82e792cf1f7636becbc183ab6ea490cf563af7b3318d86246bce203780f79465ad5f457047ad348704732185d3ce952109fded6
7
+ data.tar.gz: e9420245c4fdad743a6cec795c2e2c8846d247181436ec3bc0d41f4a1868dc0c793a745f0744be4f565e31976ff5a7a3b70d0ac69a49ebd6c97b271b2727d1d2
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ViewComponent DevTools contributors
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,9 @@
1
+ # view_component_devtools gem
2
+
3
+ Development-only Rails instrumentation for the ViewComponent DevTools browser
4
+ panel. The gem subscribes to ViewComponent's native ActiveSupport notification,
5
+ stores a bounded render tree in request-local state, and exposes it through an
6
+ explicit layout helper.
7
+
8
+ See the repository [README](../../README.md) for installation, configuration,
9
+ security behavior, and the browser extension setup.
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponentDevtools
4
+ module Component
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ module ClassMethods
10
+ def devtools_props(*names)
11
+ @view_component_devtools_props ||= []
12
+ @view_component_devtools_props.concat(names.map(&:to_sym)).uniq!
13
+ end
14
+
15
+ def view_component_devtools_prop_names
16
+ inherited = superclass.respond_to?(:view_component_devtools_prop_names) ?
17
+ superclass.view_component_devtools_prop_names : []
18
+ initializer_names = instance_method(:initialize).parameters.filter_map do |kind, name|
19
+ name if %i[req opt keyreq key].include?(kind)
20
+ end
21
+ declared = @view_component_devtools_props || []
22
+ trusted = inherited + declared
23
+
24
+ (trusted + initializer_names).uniq.select do |name|
25
+ prop_reader?(name, trusted: trusted.include?(name))
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def prop_reader?(name, trusted:)
32
+ return false unless public_method_defined?(name)
33
+
34
+ method = instance_method(name)
35
+ callable_without_arguments = method.parameters.none? do |kind, _|
36
+ %i[req keyreq].include?(kind)
37
+ end
38
+ callable_without_arguments && (trusted || component_class_owner?(method.owner))
39
+ end
40
+
41
+ def component_class_owner?(owner)
42
+ return false unless owner.is_a?(Class)
43
+
44
+ if defined?(ViewComponent::Base) && self < ViewComponent::Base
45
+ owner < ViewComponent::Base
46
+ else
47
+ self <= owner
48
+ end
49
+ end
50
+ end
51
+
52
+ def render_in(...)
53
+ return super unless State.active?
54
+
55
+ metadata = {
56
+ name: self.class.name,
57
+ source_location: component_source_location,
58
+ props: self.class.view_component_devtools_prop_names.to_h do |name|
59
+ [name, public_send(name)]
60
+ end
61
+ }
62
+
63
+ State.with_component(metadata) { super }
64
+ end
65
+
66
+ private
67
+
68
+ def component_source_location
69
+ if self.class.respond_to?(:identifier)
70
+ identifier = self.class.identifier
71
+ return identifier if identifier
72
+ end
73
+
74
+ self.class.instance_method(:initialize).source_location&.join(":")
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponentDevtools
4
+ class Configuration
5
+ DEFAULT_REDACT_KEYS = [
6
+ /authorization/i,
7
+ /cookie/i,
8
+ /credential/i,
9
+ /passw(or)?d/i,
10
+ /private.?key/i,
11
+ /secret/i,
12
+ /session/i,
13
+ /token/i
14
+ ].freeze
15
+
16
+ attr_accessor :allow_non_development,
17
+ :enabled,
18
+ :max_collection_size,
19
+ :max_components,
20
+ :max_depth,
21
+ :max_string_length,
22
+ :redact_keys
23
+
24
+ def initialize
25
+ @enabled = false
26
+ @allow_non_development = false
27
+ @max_components = 500
28
+ @max_depth = 8
29
+ @max_collection_size = 50
30
+ @max_string_length = 2_000
31
+ @redact_keys = DEFAULT_REDACT_KEYS.dup
32
+ end
33
+
34
+ def enabled_for?(environment)
35
+ enabled && (environment.to_s == "development" || allow_non_development)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module ViewComponentDevtools
6
+ class Engine < ::Rails::Engine
7
+ initializer "view_component_devtools.middleware" do |application|
8
+ application.middleware.use Middleware
9
+ end
10
+
11
+ initializer "view_component_devtools.helper" do
12
+ ActiveSupport.on_load(:action_view) { include Helper }
13
+ end
14
+
15
+ initializer "view_component_devtools.subscriber" do
16
+ Subscriber.install!
17
+ end
18
+
19
+ config.after_initialize do
20
+ next unless ViewComponentDevtools.capture_enabled?
21
+
22
+ Rails.application.config.view_component.instrumentation_enabled = true
23
+ require "view_component/instrumentation"
24
+ unless ViewComponent::Base < ViewComponent::Instrumentation
25
+ ViewComponent::Base.prepend(ViewComponent::Instrumentation)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/output_safety"
4
+ require "json"
5
+
6
+ module ViewComponentDevtools
7
+ module Helper
8
+ ELEMENT_ID = "view-component-devtools-data"
9
+
10
+ def view_component_devtools_payload
11
+ return "".html_safe unless State.active?
12
+
13
+ json = JSON.generate(State.current.payload)
14
+ .gsub("<", "\\u003c")
15
+ .gsub(">", "\\u003e")
16
+ .gsub("&", "\\u0026")
17
+ .gsub("\u2028", "\\u2028")
18
+ .gsub("\u2029", "\\u2029")
19
+
20
+ %(<script type="application/json" id="#{ELEMENT_ID}" data-version="1">#{json}</script>).html_safe
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/body_proxy"
4
+
5
+ module ViewComponentDevtools
6
+ class Middleware
7
+ def initialize(app)
8
+ @app = app
9
+ end
10
+
11
+ def call(environment)
12
+ return app.call(environment) unless ViewComponentDevtools.capture_enabled?
13
+
14
+ State.start!(request_id: environment["action_dispatch.request_id"] || environment["HTTP_X_REQUEST_ID"])
15
+ completed = false
16
+
17
+ begin
18
+ status, headers, body = app.call(environment)
19
+ response = [status, headers, Rack::BodyProxy.new(body) { State.reset! }]
20
+ completed = true
21
+ response
22
+ ensure
23
+ State.reset! unless completed
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :app
30
+ end
31
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module ViewComponentDevtools
7
+ class Normalizer
8
+ REDACTED = "[REDACTED]"
9
+
10
+ def initialize(configuration = ViewComponentDevtools.configuration)
11
+ @configuration = configuration
12
+ end
13
+
14
+ def call(value)
15
+ normalize(value, depth: 0, ancestors: {})
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :configuration
21
+
22
+ def normalize(value, depth:, ancestors:)
23
+ return "[maximum depth exceeded]" if depth > configuration.max_depth
24
+
25
+ case value
26
+ when nil, true, false, Integer
27
+ value
28
+ when Float
29
+ value.finite? ? value : "[non-finite Float]"
30
+ when String
31
+ truncate(sanitize_string(value))
32
+ when Symbol
33
+ truncate(sanitize_string(value.to_s))
34
+ when Time, DateTime
35
+ value.iso8601(3)
36
+ when Date
37
+ value.iso8601
38
+ when Array
39
+ normalize_array(value, depth:, ancestors:)
40
+ when Hash
41
+ normalize_hash(value, depth:, ancestors:)
42
+ else
43
+ "[unsupported #{value.class.name}]"
44
+ end
45
+ end
46
+
47
+ def normalize_array(value, depth:, ancestors:)
48
+ with_cycle_guard(value, ancestors) do |next_ancestors|
49
+ entries = value.first(configuration.max_collection_size).map do |entry|
50
+ normalize(entry, depth: depth + 1, ancestors: next_ancestors)
51
+ end
52
+ entries << "[#{value.length - entries.length} more items]" if value.length > entries.length
53
+ entries
54
+ end
55
+ end
56
+
57
+ def normalize_hash(value, depth:, ancestors:)
58
+ with_cycle_guard(value, ancestors) do |next_ancestors|
59
+ entries = value.first(configuration.max_collection_size).to_h
60
+ normalized = entries.each_with_object({}) do |(key, entry), result|
61
+ original_key = stringify_key(key)
62
+ next unless original_key
63
+
64
+ normalized_key = truncate(original_key)
65
+ result[normalized_key] = if redact?(original_key)
66
+ REDACTED
67
+ else
68
+ normalize(entry, depth: depth + 1, ancestors: next_ancestors)
69
+ end
70
+ end
71
+ normalized["__truncated__"] = value.length - entries.length if value.length > entries.length
72
+ normalized
73
+ end
74
+ end
75
+
76
+ def stringify_key(key)
77
+ return sanitize_string(key.to_s) if key.is_a?(String) || key.is_a?(Symbol)
78
+
79
+ nil
80
+ end
81
+
82
+ def redact?(key)
83
+ configuration.redact_keys.any? do |pattern|
84
+ pattern.is_a?(Regexp) ? pattern.match?(key) : key.downcase.include?(pattern.to_s.downcase)
85
+ end
86
+ end
87
+
88
+ def truncate(value)
89
+ return value if value.length <= configuration.max_string_length
90
+
91
+ "#{value[0, configuration.max_string_length]}…"
92
+ end
93
+
94
+ def sanitize_string(value)
95
+ value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "�")
96
+ end
97
+
98
+ def with_cycle_guard(value, ancestors)
99
+ return "[circular reference]" if ancestors.key?(value.object_id)
100
+
101
+ yield ancestors.merge(value.object_id => true)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/isolated_execution_state"
4
+ require "securerandom"
5
+ require "time"
6
+
7
+ module ViewComponentDevtools
8
+ module State
9
+ KEY = :view_component_devtools_session
10
+
11
+ class << self
12
+ def start!(request_id: nil)
13
+ raise "ViewComponent DevTools capture already active" if active?
14
+
15
+ ActiveSupport::IsolatedExecutionState[KEY] = Session.new(
16
+ request_id: request_id || SecureRandom.uuid,
17
+ configuration: ViewComponentDevtools.configuration
18
+ )
19
+ end
20
+
21
+ def current
22
+ ActiveSupport::IsolatedExecutionState[KEY]
23
+ end
24
+
25
+ def active?
26
+ !current.nil?
27
+ end
28
+
29
+ def reset!
30
+ ActiveSupport::IsolatedExecutionState.delete(KEY)
31
+ end
32
+
33
+ def with_component(metadata)
34
+ return yield unless current
35
+
36
+ current.push_component(metadata)
37
+ yield
38
+ ensure
39
+ current&.pop_component(metadata)
40
+ end
41
+ end
42
+
43
+ class Session
44
+ attr_reader :request_id
45
+
46
+ def initialize(request_id:, configuration:)
47
+ @request_id = request_id
48
+ @configuration = configuration
49
+ @components = []
50
+ @component_stack = []
51
+ @event_stack = []
52
+ @component_count = 0
53
+ @truncated = false
54
+ @normalizer = Normalizer.new(configuration)
55
+ end
56
+
57
+ def push_component(metadata)
58
+ component_stack << metadata
59
+ end
60
+
61
+ def pop_component(metadata)
62
+ actual = component_stack.pop
63
+ raise "ViewComponent DevTools component stack mismatch" unless actual.equal?(metadata)
64
+ end
65
+
66
+ def start_event(event_id, payload, start_time)
67
+ node = build_node(payload, start_time)
68
+ event_stack << {event_id:, node:}
69
+ return unless node
70
+
71
+ parent = event_stack.reverse_each.drop(1).find { |entry| entry[:node] }&.fetch(:node)
72
+ (parent ? parent[:children] : components) << node
73
+ end
74
+
75
+ def finish_event(event_id, payload, finish_time)
76
+ entry = event_stack.pop
77
+ raise "ViewComponent DevTools event stack mismatch" unless entry&.fetch(:event_id) == event_id
78
+
79
+ node = entry[:node]
80
+ return unless node
81
+
82
+ node[:durationMs] = ((finish_time - node.delete(:startedAt)) * 1_000).round(3)
83
+ node[:templateLocation] = payload[:view_identifier] if payload[:view_identifier]
84
+ end
85
+
86
+ def payload
87
+ {
88
+ version: 1,
89
+ requestId: request_id,
90
+ generatedAt: Time.now.utc.iso8601(3),
91
+ truncated: @truncated,
92
+ components:
93
+ }
94
+ end
95
+
96
+ private
97
+
98
+ attr_reader :component_stack, :components, :configuration, :event_stack, :normalizer
99
+
100
+ def build_node(payload, start_time)
101
+ if @component_count >= configuration.max_components
102
+ @truncated = true
103
+ return nil
104
+ end
105
+
106
+ @component_count += 1
107
+ metadata = component_stack.last
108
+ metadata = nil unless metadata&.fetch(:name, nil) == payload[:name]
109
+ {
110
+ id: "#{request_id}:#{@component_count}",
111
+ name: payload[:name] || metadata&.fetch(:name, nil) || "AnonymousComponent",
112
+ sourceLocation: payload[:identifier] || metadata&.fetch(:source_location, nil),
113
+ templateLocation: payload[:view_identifier],
114
+ durationMs: nil,
115
+ props: normalizer.call(metadata&.fetch(:props, {}) || {}),
116
+ children: [],
117
+ startedAt: start_time
118
+ }
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+
5
+ module ViewComponentDevtools
6
+ class Subscriber
7
+ EVENT_NAMES = ["render.view_component", "!render.view_component"].freeze
8
+
9
+ class << self
10
+ def install!
11
+ @install_mutex ||= Mutex.new
12
+ @install_mutex.synchronize do
13
+ @subscriptions ||= EVENT_NAMES.map do |event_name|
14
+ ActiveSupport::Notifications.subscribe(event_name, new)
15
+ end
16
+ end
17
+ end
18
+ end
19
+
20
+ def start(_name, event_id, payload)
21
+ State.current&.start_event(event_id, payload, monotonic_time)
22
+ end
23
+
24
+ def finish(_name, event_id, payload)
25
+ State.current&.finish_event(event_id, payload, monotonic_time)
26
+ end
27
+
28
+ private
29
+
30
+ def monotonic_time
31
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ViewComponentDevtools
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "view_component_devtools/version"
4
+ require_relative "view_component_devtools/configuration"
5
+ require "rails"
6
+ require "view_component"
7
+
8
+ module ViewComponentDevtools
9
+ class << self
10
+ def configuration
11
+ @configuration ||= Configuration.new
12
+ end
13
+
14
+ def configure
15
+ yield configuration
16
+ end
17
+
18
+ def capture_enabled?(environment = rails_environment)
19
+ configuration.enabled_for?(environment)
20
+ end
21
+
22
+ private
23
+
24
+ def rails_environment
25
+ defined?(Rails) && Rails.respond_to?(:env) ? Rails.env : ENV.fetch("RAILS_ENV", "development")
26
+ end
27
+ end
28
+ end
29
+
30
+ require_relative "view_component_devtools/normalizer"
31
+ require_relative "view_component_devtools/state"
32
+ require_relative "view_component_devtools/component"
33
+ require_relative "view_component_devtools/subscriber"
34
+ require_relative "view_component_devtools/middleware"
35
+ require_relative "view_component_devtools/helper"
36
+ require_relative "view_component_devtools/engine"
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: view_component_devtools
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ViewComponent DevTools contributors
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-08-11 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rack
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.2'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '4'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '2.2'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '4'
52
+ - !ruby/object:Gem::Dependency
53
+ name: railties
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.1'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '7.1'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9'
72
+ - !ruby/object:Gem::Dependency
73
+ name: view_component
74
+ requirement: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.0'
79
+ - - "<"
80
+ - !ruby/object:Gem::Version
81
+ version: '5'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ - - "<"
90
+ - !ruby/object:Gem::Version
91
+ version: '5'
92
+ description: Captures a safe ViewComponent render tree for a browser DevTools panel.
93
+ executables: []
94
+ extensions: []
95
+ extra_rdoc_files: []
96
+ files:
97
+ - LICENSE.txt
98
+ - README.md
99
+ - lib/view_component_devtools.rb
100
+ - lib/view_component_devtools/component.rb
101
+ - lib/view_component_devtools/configuration.rb
102
+ - lib/view_component_devtools/engine.rb
103
+ - lib/view_component_devtools/helper.rb
104
+ - lib/view_component_devtools/middleware.rb
105
+ - lib/view_component_devtools/normalizer.rb
106
+ - lib/view_component_devtools/state.rb
107
+ - lib/view_component_devtools/subscriber.rb
108
+ - lib/view_component_devtools/version.rb
109
+ homepage: https://github.com/zaviermiller/view_component_devtools
110
+ licenses:
111
+ - MIT
112
+ metadata:
113
+ homepage_uri: https://github.com/zaviermiller/view_component_devtools
114
+ source_code_uri: https://github.com/zaviermiller/view_component_devtools/tree/main/packages/view_component_devtools
115
+ rubygems_mfa_required: 'true'
116
+ rdoc_options: []
117
+ require_paths:
118
+ - lib
119
+ required_ruby_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '3.2'
124
+ required_rubygems_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: '0'
129
+ requirements: []
130
+ rubygems_version: 3.6.2
131
+ specification_version: 4
132
+ summary: Development-only component inspection for Rails ViewComponent
133
+ test_files: []