reactionview 0.3.0 → 0.4.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.
@@ -23,9 +23,15 @@ module ReActionView
23
23
  # Enable debug mode in development (adds debug attributes to HTML)
24
24
  config.debug_mode = Rails.env.development?
25
25
 
26
+ # Path used for editor "open in editor" links (optional, defaults to Rails.root)
27
+ # config.project_path = ENV.fetch('PROJECT_PATH', Rails.root.to_s)
28
+
26
29
  # Validation mode (:raise, :overlay, or :none) — defaults to :raise in test, :overlay otherwise
27
30
  # config.validation_mode = :overlay
28
31
 
32
+ # How to handle templates that come from gems (:fallback, :skip, or :compile), defaults to :fallback
33
+ # config.external_template_mode = :skip
34
+
29
35
  # Add custom transform visitors to process templates before compilation
30
36
  # config.transform_visitors = [
31
37
  # Herb::Visitor::new
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ReActionView
6
+ class AssetManifest
7
+ def initialize(assets)
8
+ @assets = assets
9
+ end
10
+
11
+ def path
12
+ config = @assets&.config
13
+
14
+ return unless config.respond_to?(:manifest_path)
15
+
16
+ config.manifest_path
17
+ end
18
+
19
+ def mtime
20
+ return if path.nil?
21
+
22
+ File.mtime(path)
23
+ rescue SystemCallError
24
+ nil
25
+ end
26
+
27
+ def exist?
28
+ !mtime.nil?
29
+ end
30
+
31
+ def missing_assets
32
+ precompiled = logical_paths
33
+
34
+ return [] if precompiled.empty?
35
+
36
+ ReActionView::Railtie::PRECOMPILE_ASSETS - precompiled
37
+ end
38
+
39
+ def stale?
40
+ exist? && !missing_assets.empty?
41
+ end
42
+
43
+ def in_use?
44
+ @assets.respond_to?(:resolver) && @assets.resolver.respond_to?(:manifest_path)
45
+ end
46
+
47
+ def explanation
48
+ <<~MESSAGE
49
+ ReActionView's dev tools assets are missing from your precompiled assets.
50
+ #{status}
51
+
52
+ To fix this, delete the precompiled assets:
53
+
54
+ bin/rails assets:clobber
55
+
56
+ That is safe in development. Without public/assets/, Propshaft serves each asset straight from
57
+ the directory it lives in, including ReActionView's.
58
+
59
+ Missing: #{missing_assets.join(", ")}
60
+ Manifest: #{path}
61
+
62
+ public/assets/ was most likely left behind by `RAILS_ENV=production bin/rails assets:precompile`,
63
+ and a production precompile doesn't include ReActionView's dev tools.
64
+ MESSAGE
65
+ end
66
+
67
+ private
68
+
69
+ def status
70
+ if in_use?
71
+ "Because public/assets/.manifest.json exists, Propshaft serves every asset from public/assets/\n" \
72
+ "and doesn't look anywhere else, which is why the lookup failed."
73
+ else
74
+ "This server booted before public/assets/.manifest.json existed, so pages still render for now.\n" \
75
+ "After the next restart Propshaft will serve assets from public/assets/ only, and rendering\n" \
76
+ "will fail then."
77
+ end
78
+ end
79
+
80
+ def logical_paths
81
+ return [] if path.nil?
82
+
83
+ JSON.parse(File.read(path)).keys
84
+ rescue SystemCallError, JSON::ParserError
85
+ []
86
+ end
87
+ end
88
+ end
@@ -5,12 +5,36 @@ module ReActionView
5
5
  attr_accessor :intercept_erb
6
6
  attr_accessor :debug_mode
7
7
  attr_accessor :transform_visitors
8
+
9
+ EXTERNAL_TEMPLATE_MODES = %i[fallback skip compile].freeze
10
+
11
+ attr_writer :dev_server_port
12
+ attr_writer :project_path
8
13
  attr_writer :validation_mode
9
14
 
10
15
  def initialize
11
16
  @intercept_erb = false
12
17
  @debug_mode = nil
18
+ @dev_server_port = nil
19
+ @external_template_mode = nil
13
20
  @transform_visitors = []
21
+ @project_path = nil
22
+ end
23
+
24
+ def external_template_mode
25
+ @external_template_mode || :fallback
26
+ end
27
+
28
+ def external_template_mode=(mode)
29
+ unless mode.nil? || EXTERNAL_TEMPLATE_MODES.include?(mode)
30
+ raise ArgumentError, "external_template_mode must be one of :fallback, :skip, or :compile, got #{mode.inspect}"
31
+ end
32
+
33
+ @external_template_mode = mode
34
+ end
35
+
36
+ def project_path
37
+ @project_path || Rails.root.to_s
14
38
  end
15
39
 
16
40
  def validation_mode
@@ -36,6 +60,13 @@ module ReActionView
36
60
 
37
61
  development?
38
62
  end
63
+
64
+ def dev_server_port
65
+ return @dev_server_port if @dev_server_port
66
+ return nil unless development?
67
+
68
+ ::Herb.dev_server_port(Rails.root.to_s)
69
+ end
39
70
  end
40
71
 
41
72
  def self.config
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../asset_manifest"
4
+ require_relative "../stale_asset_manifest_error"
5
+
6
+ module ReActionView
7
+ module Middleware
8
+ class AssetManifestCheck
9
+ def initialize(app, assets: nil, logger: nil)
10
+ @app = app
11
+ @assets = assets
12
+ @logger = logger
13
+ @mutex = Mutex.new
14
+ @checked_mtime = nil
15
+ end
16
+
17
+ def call(env)
18
+ warn_about_upcoming_failure
19
+
20
+ @app.call(env)
21
+ rescue StandardError => e
22
+ raise unless dev_tools_asset_missing?(e)
23
+
24
+ manifest = self.manifest
25
+
26
+ raise unless manifest.stale?
27
+
28
+ raise StaleAssetManifestError.for(manifest)
29
+ end
30
+
31
+ private
32
+
33
+ def assets
34
+ @assets || (defined?(Rails) && Rails.application&.assets)
35
+ end
36
+
37
+ def logger
38
+ @logger || (defined?(Rails) && Rails.logger)
39
+ end
40
+
41
+ def manifest
42
+ AssetManifest.new(assets)
43
+ end
44
+
45
+ def warn_about_upcoming_failure
46
+ manifest = self.manifest
47
+ mtime = manifest.mtime
48
+
49
+ return if mtime.nil?
50
+
51
+ @mutex.synchronize do
52
+ return if @checked_mtime == mtime
53
+
54
+ @checked_mtime = mtime
55
+
56
+ return if manifest.in_use? || !manifest.stale?
57
+
58
+ log("[ReActionView] #{manifest.explanation}")
59
+ end
60
+ end
61
+
62
+ def dev_tools_asset_missing?(error)
63
+ error_class = missing_asset_error_class
64
+
65
+ return false if error_class.nil?
66
+
67
+ while error
68
+ if error.is_a?(error_class) &&
69
+ ReActionView::Railtie::PRECOMPILE_ASSETS.any? { |asset| error.message.include?(asset) }
70
+ return true
71
+ end
72
+
73
+ error = error.cause
74
+ end
75
+
76
+ false
77
+ end
78
+
79
+ def missing_asset_error_class
80
+ return unless defined?(::Propshaft::MissingAssetError)
81
+
82
+ ::Propshaft::MissingAssetError
83
+ end
84
+
85
+ def log(message)
86
+ if logger
87
+ logger.warn(message)
88
+ else
89
+ warn(message)
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -14,8 +14,8 @@ module ReActionView
14
14
  reactionview-dev-tools.umd.js
15
15
  ].freeze
16
16
 
17
- initializer "reactionview.assets" do |app|
18
- if ReActionView.config.development? && app.config.respond_to?(:assets)
17
+ initializer "reactionview.assets", after: :load_config_initializers do |app|
18
+ if ReActionView.config.debug_mode_enabled? && app.config.respond_to?(:assets)
19
19
  gem_root = Gem::Specification.find_by_name("reactionview").gem_dir
20
20
 
21
21
  app.config.assets.paths << File.join(gem_root, "app", "assets", "javascripts")
@@ -23,6 +23,14 @@ module ReActionView
23
23
  end
24
24
  end
25
25
 
26
+ initializer "reactionview.asset_manifest_check" do |app|
27
+ next unless ReActionView.config.development?
28
+
29
+ require_relative "middleware/asset_manifest_check"
30
+
31
+ app.middleware.use ReActionView::Middleware::AssetManifestCheck
32
+ end
33
+
26
34
  initializer "reactionview.register_herb_handler" do
27
35
  ActiveSupport.on_load(:action_view) do
28
36
  ActionView::Template.register_template_handler :herb, ReActionView::Template::Handlers::Herb
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReActionView
4
+ class StaleAssetManifestError < StandardError
5
+ def self.for(manifest)
6
+ new(manifest.explanation)
7
+ end
8
+ end
9
+ end
@@ -4,14 +4,50 @@ module ReActionView
4
4
  class Template
5
5
  module Handlers
6
6
  class ERB < ActionView::Template::Handlers::ERB
7
+ include ReActionView::Template::LocalTemplate
8
+
7
9
  autoload :Herb, "reactionview/template/handlers/herb/herb"
8
10
 
9
11
  def call(template, source)
10
- if template.format == :html && ReActionView.config.intercept_erb
11
- ::ReActionView::Template::Handlers::Herb.call(template, source)
12
- else
13
- super
14
- end
12
+ return super unless intercept_template?(template)
13
+
14
+ ::ReActionView::Template::Handlers::Herb.call(
15
+ template, source, validation_mode: herb_validation_mode(template)
16
+ )
17
+ rescue StandardError => e
18
+ raise unless fall_back_to_erb?(template)
19
+
20
+ log_external_template_error(template, e)
21
+
22
+ super
23
+ end
24
+
25
+ private
26
+
27
+ def intercept_template?(template)
28
+ return false unless template.format == :html && ReActionView.config.intercept_erb
29
+
30
+ local_template?(template) || ReActionView.config.external_template_mode != :skip
31
+ end
32
+
33
+ def herb_validation_mode(template)
34
+ return nil if local_template?(template)
35
+ return nil unless ReActionView.config.external_template_mode == :fallback
36
+
37
+ :raise
38
+ end
39
+
40
+ def fall_back_to_erb?(template)
41
+ !local_template?(template) && ReActionView.config.external_template_mode == :fallback
42
+ end
43
+
44
+ def log_external_template_error(template, error)
45
+ return unless defined?(Rails.logger) && Rails.logger
46
+
47
+ Rails.logger.warn(
48
+ "[ReActionView] #{template.identifier} could not be compiled by Herb, " \
49
+ "falling back to ActionView::Template::Handlers::ERB: #{error.message.strip.lines.first}"
50
+ )
15
51
  end
16
52
  end
17
53
  end
@@ -4,24 +4,30 @@ module ReActionView
4
4
  class Template
5
5
  module Handlers
6
6
  class Herb < ActionView::Template::Handlers::ERB
7
+ include ReActionView::Template::LocalTemplate
8
+
7
9
  autoload :Herb, "reactionview/template/handlers/herb/herb"
8
10
 
9
11
  class_attribute :erb_implementation, default: Handlers::Herb::Herb
10
12
 
11
- def call(template, source)
13
+ def self.call(template, source, validation_mode: nil)
14
+ new.call(template, source, validation_mode: validation_mode)
15
+ end
16
+
17
+ def call(template, source, validation_mode: nil)
12
18
  visitors = []
13
19
 
14
20
  if ::ReActionView.config.debug_mode_enabled? && local_template?(template)
15
21
  visitors << ::Herb::Engine::DebugVisitor.new(
16
- file_path: template.identifier,
17
- project_path: Rails.root.to_s
22
+ file_path: translate_path_for_editor(template.identifier),
23
+ project_path: ::ReActionView.config.project_path
18
24
  )
19
25
  end
20
26
 
21
27
  config = {
22
28
  filename: template.identifier,
23
29
  project_path: Rails.root.to_s,
24
- validation_mode: ReActionView.config.validation_mode,
30
+ validation_mode: validation_mode || ReActionView.config.validation_mode,
25
31
  content_for_head: reactionview_dev_tools_markup(template),
26
32
  visitors: visitors + ReActionView.config.transform_visitors,
27
33
  }
@@ -37,12 +43,6 @@ module ReActionView
37
43
  template.identifier.include?("/layouts/")
38
44
  end
39
45
 
40
- def local_template?(template)
41
- return true unless template.respond_to?(:identifier) && template.identifier
42
-
43
- template.identifier.start_with?(Rails.root.to_s)
44
- end
45
-
46
46
  def active_support_editor
47
47
  return unless defined?(ActiveSupport::Editor)
48
48
  return if ActiveSupport::Editor.current.blank?
@@ -50,6 +50,15 @@ module ReActionView
50
50
  ActiveSupport::Editor.current.instance_variable_get(:@url_pattern).split("://").first
51
51
  end
52
52
 
53
+ def translate_path_for_editor(template_path)
54
+ rails_root = Rails.root.to_s
55
+ project_path = ::ReActionView.config.project_path
56
+
57
+ return template_path if project_path == rails_root
58
+
59
+ template_path.to_s.sub(rails_root, project_path)
60
+ end
61
+
53
62
  def editor_meta_tag
54
63
  editor_name = active_support_editor || ENV["RAILS_EDITOR"] || ENV.fetch("EDITOR", nil)
55
64
 
@@ -58,29 +67,36 @@ module ReActionView
58
67
  %(<meta name="herb-default-editor" content="#{editor_name}">)
59
68
  end
60
69
 
70
+ def dev_server_port_meta_tag
71
+ port = ::ReActionView.config.dev_server_port
72
+
73
+ return if port.blank?
74
+
75
+ %(<meta name="herb-dev-server-port" content="#{port}">)
76
+ end
77
+
61
78
  def reactionview_dev_tools_markup(template)
79
+ return nil unless ::ReActionView.config.debug_mode_enabled?
62
80
  return nil unless layout_template?(template)
63
81
  return nil unless local_template?(template)
64
82
 
65
- markup = +""
83
+ <<~HTML
84
+ <meta name="herb-debug-mode" content="true">
85
+ <meta name="herb-project-path" content="#{Rails.root}">
86
+ #{dev_server_port_meta_tag}
87
+ #{editor_meta_tag}
66
88
 
67
- if ::ReActionView.config.debug_mode_enabled?
68
- markup << <<~HTML
69
- <meta name="herb-debug-mode" content="true">
70
- <meta name="herb-project-path" content="#{Rails.root}">
71
- #{editor_meta_tag}
72
-
73
- #{ActionController::Base.new.view_context.javascript_include_tag "reactionview-dev-tools.umd.js", defer: true}
74
- HTML
75
- end
89
+ #{ActionController::Base.new.view_context.javascript_include_tag "reactionview-dev-tools.umd.js", defer: true}
90
+ #{dismiss_hint_template}
91
+ HTML
92
+ end
76
93
 
77
- if ::ReActionView.config.validation_mode == :overlay
78
- markup << <<~HTML
79
- <template data-herb-dismiss-hint>You can also disable this overlay by setting <code style="color: #ffeb3b; font-family: monospace; font-size: 12pt;">config.validation_mode = :none</code> in <code style="color: #ffeb3b; font-family: monospace; font-size: 12pt;">config/initializers/reactionview.rb</code>.</template>
80
- HTML
81
- end
94
+ def dismiss_hint_template
95
+ return unless ::ReActionView.config.validation_mode == :overlay
82
96
 
83
- markup.presence
97
+ <<~HTML.chomp
98
+ <template data-herb-dismiss-hint>You can also disable this overlay by setting <code style="color: #ffeb3b; font-family: monospace; font-size: 12pt;">config.validation_mode = :none</code> in <code style="color: #ffeb3b; font-family: monospace; font-size: 12pt;">config/initializers/reactionview.rb</code>.</template>
99
+ HTML
84
100
  end
85
101
  end
86
102
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReActionView
4
+ class Template
5
+ module LocalTemplate
6
+ private
7
+
8
+ def local_template?(template)
9
+ return true unless template.respond_to?(:identifier) && template.identifier
10
+ return false if vendored_template?(template)
11
+
12
+ template.identifier.start_with?(Rails.root.to_s)
13
+ end
14
+
15
+ def vendored_template?(template)
16
+ return false unless defined?(Bundler)
17
+
18
+ template.identifier.start_with?(Bundler.bundle_path.to_s)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReActionView
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/reactionview.rb CHANGED
@@ -17,6 +17,8 @@ require_relative "reactionview/config"
17
17
 
18
18
  # require_relative "reactionview/source_annotation_extractor"
19
19
 
20
+ require_relative "reactionview/template/local_template"
21
+
20
22
  require_relative "reactionview/template/handlers/erb"
21
23
  require_relative "reactionview/template/handlers/herb"
22
24
  require_relative "reactionview/template/handlers/herb/herb"
data/reactionview.gemspec CHANGED
@@ -36,5 +36,6 @@ Gem::Specification.new do |spec|
36
36
  spec.require_paths = ["lib"]
37
37
 
38
38
  spec.add_dependency "actionview", ">= 7.0"
39
- spec.add_dependency "herb", ">= 0.9.0", "< 1.0.0"
39
+ spec.add_dependency "cruise"
40
+ spec.add_dependency "herb", ">= 0.10.0", "< 0.11.0"
40
41
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reactionview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marco Roth
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-03-18 00:00:00.000000000 Z
10
+ date: 2026-08-18 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionview
@@ -23,26 +23,40 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: cruise
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: herb
28
42
  requirement: !ruby/object:Gem::Requirement
29
43
  requirements:
30
44
  - - ">="
31
45
  - !ruby/object:Gem::Version
32
- version: 0.9.0
46
+ version: 0.10.0
33
47
  - - "<"
34
48
  - !ruby/object:Gem::Version
35
- version: 1.0.0
49
+ version: 0.11.0
36
50
  type: :runtime
37
51
  prerelease: false
38
52
  version_requirements: !ruby/object:Gem::Requirement
39
53
  requirements:
40
54
  - - ">="
41
55
  - !ruby/object:Gem::Version
42
- version: 0.9.0
56
+ version: 0.10.0
43
57
  - - "<"
44
58
  - !ruby/object:Gem::Version
45
- version: 1.0.0
59
+ version: 0.11.0
46
60
  description: An ActionView-compatible ERB engine with modern DX - re-imagined with
47
61
  Herb.
48
62
  email:
@@ -61,11 +75,15 @@ files:
61
75
  - lib/generators/reactionview/install_generator.rb
62
76
  - lib/reaction_view.rb
63
77
  - lib/reactionview.rb
78
+ - lib/reactionview/asset_manifest.rb
64
79
  - lib/reactionview/config.rb
80
+ - lib/reactionview/middleware/asset_manifest_check.rb
65
81
  - lib/reactionview/railtie.rb
82
+ - lib/reactionview/stale_asset_manifest_error.rb
66
83
  - lib/reactionview/template/handlers/erb.rb
67
84
  - lib/reactionview/template/handlers/herb.rb
68
85
  - lib/reactionview/template/handlers/herb/herb.rb
86
+ - lib/reactionview/template/local_template.rb
69
87
  - lib/reactionview/version.rb
70
88
  - reactionview.gemspec
71
89
  - sig/reactionview.rbs