reactionview 0.4.1 → 0.6.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.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReActionView
4
+ module Slots
5
+ class Resolver < ActionView::FileSystemResolver
6
+ private
7
+
8
+ def build_unbound_template(template)
9
+ unbound = super
10
+ details = unbound.details
11
+
12
+ return unbound unless details.format == :html
13
+
14
+ ActionView::UnboundTemplate.new(
15
+ source_for_template(template),
16
+ template,
17
+ details: ActionView::TemplateDetails.new(details.locale, details.handler, Slots::FORMAT, details.variant),
18
+ virtual_path: unbound.virtual_path
19
+ )
20
+ end
21
+
22
+ def unbound_templates_from_path(path)
23
+ super.select { |unbound| unbound.format == Slots::FORMAT }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ReActionView
6
+ module Slots
7
+ # Parses the `Herb-State` request header a client sends with a values request.
8
+ #
9
+ # The header carries the client's state so the compiled values program evaluates
10
+ # the branches the client is showing. The parse is lenient on purpose, since a
11
+ # malformed or oversized header must degrade to the compiled defaults and never
12
+ # break a render.
13
+ #
14
+ module StateOverrides
15
+ HEADER = "Herb-State" #: String
16
+ MAX_BYTES = 8192 #: Integer
17
+
18
+ #: (String?) -> Hash[String, Hash[String, untyped]]?
19
+ def self.parse(header_value)
20
+ return nil if header_value.nil? || header_value.strip.empty?
21
+ return nil if header_value.bytesize > MAX_BYTES
22
+
23
+ raw = ::JSON.parse(header_value)
24
+
25
+ return nil unless raw.is_a?(::Hash)
26
+
27
+ overrides = raw.select { |key, value| key.is_a?(::String) && value.is_a?(::Hash) }
28
+
29
+ overrides.empty? ? nil : overrides
30
+ rescue ::JSON::ParserError
31
+ nil
32
+ end
33
+ end
34
+
35
+ module StateOverridesHelper
36
+ #: () -> Hash[String, Hash[String, untyped]]?
37
+ def __herb_state_overrides
38
+ return @__herb_state_overrides if defined?(@__herb_state_overrides)
39
+
40
+ header = __herb_values_request? ? request.headers[StateOverrides::HEADER] : nil
41
+
42
+ @__herb_state_overrides = StateOverrides.parse(header)
43
+ end
44
+
45
+ #: () -> bool
46
+ def __herb_values_request?
47
+ return false unless respond_to?(:request) && request
48
+ return false unless request.respond_to?(:format)
49
+
50
+ request.format&.symbol == Slots::FORMAT
51
+ end
52
+
53
+ #: (?String?, ?untyped) -> untyped
54
+ def herb_state(name = nil, default = nil)
55
+ overrides = __herb_state_overrides
56
+
57
+ return overrides if name.nil?
58
+ return default if overrides.nil?
59
+
60
+ overrides.each_value do |states|
61
+ return states[name] if states.key?(name)
62
+ end
63
+
64
+ default
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReActionView
4
+ module Slots
5
+ FORMAT = :slots #: Symbol
6
+ MIME_TYPE = "application/vnd.herb.slots+json" #: String
7
+ REGION_MARKER = "herb-region:" #: String
8
+
9
+ #: () -> untyped
10
+ def self.dependencies
11
+ root = ReActionView.config.project_path
12
+
13
+ @dependencies = nil if @root != root
14
+ @root = root
15
+ @dependencies ||= ::Herb::Engine::Slots::Dependencies.new(
16
+ root,
17
+ compile: ->(source, path) { ReActionView::Template::Handlers::Herb.compile_for_dependencies(source, path) }
18
+ )
19
+ end
20
+
21
+ #: () -> void
22
+ def self.reset_dependencies!
23
+ @dependencies = nil
24
+ end
25
+
26
+ #: (String, Integer) -> String?
27
+ def self.block_program(path, index)
28
+ mtime = ::File.mtime(path)
29
+ key = [path, index, mtime]
30
+
31
+ (@block_programs_lock ||= ::Mutex.new).synchronize do
32
+ programs = (@block_programs ||= {})
33
+ programs.clear if programs.size > 128 && !programs.key?(key)
34
+ programs[key] ||= ReActionView::Template::Handlers::Herb.compile_for_block(::File.read(path), path, index)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReActionView
4
+ class Template
5
+ class Dependencies
6
+ def self.current
7
+ @current ||= new
8
+ end
9
+
10
+ def self.reset!
11
+ @current = nil
12
+ end
13
+
14
+ def engine_roots
15
+ @engine_roots ||= engines.filter_map { |engine| directory(engine.root) }
16
+ end
17
+
18
+ def gem_paths_outside(app_root)
19
+ return [] unless app_root
20
+
21
+ gem_paths.reject { |path| app_root.start_with?(path) }
22
+ end
23
+
24
+ def app_view_paths
25
+ @app_view_paths ||= (registered_view_paths - engine_view_paths).filter_map { |path| directory(path) }
26
+ end
27
+
28
+ def view_paths
29
+ @view_paths ||= registered_view_paths.filter_map { |path| directory(path) }
30
+ end
31
+
32
+ private
33
+
34
+ def gem_paths
35
+ @gem_paths ||= begin
36
+ specs = defined?(::Gem) && ::Gem.respond_to?(:loaded_specs) ? ::Gem.loaded_specs.values : []
37
+
38
+ specs.filter_map { |spec| directory(spec.full_gem_path) }
39
+ end
40
+ end
41
+
42
+ def engines
43
+ return [] unless defined?(::Rails::Engine)
44
+
45
+ @engines ||= ::Rails::Engine.subclasses.filter_map { |klass| engine_instance(klass) }
46
+ end
47
+
48
+ def engine_instance(klass)
49
+ return nil unless klass.respond_to?(:instance)
50
+
51
+ engine = klass.instance
52
+
53
+ return nil if engine.nil? || engine == application
54
+ return nil unless engine.respond_to?(:root) && engine.root
55
+
56
+ engine
57
+ rescue StandardError
58
+ nil
59
+ end
60
+
61
+ def engine_view_paths
62
+ engines.flat_map do |engine|
63
+ engine.paths["app/views"].existent
64
+ rescue StandardError
65
+ []
66
+ end
67
+ end
68
+
69
+ def registered_view_paths
70
+ return [] unless defined?(::ActionController::Base)
71
+
72
+ ::ActionController::Base.view_paths.filter_map { |resolver| resolver.path if resolver.respond_to?(:path) }
73
+ rescue StandardError
74
+ []
75
+ end
76
+
77
+ def application
78
+ ::Rails.application if defined?(::Rails) && ::Rails.respond_to?(:application)
79
+ end
80
+
81
+ def directory(path)
82
+ return nil if path.nil?
83
+
84
+ expanded = File.expand_path(path.to_s)
85
+
86
+ expanded.empty? ? nil : "#{expanded.chomp("/")}/"
87
+ end
88
+ end
89
+ end
90
+ end
@@ -6,9 +6,17 @@ module ReActionView
6
6
  class ERB < ActionView::Template::Handlers::ERB
7
7
  include ReActionView::Template::LocalTemplate
8
8
 
9
+ INTERCEPTED_FORMATS = [:html, ::ReActionView::Slots::FORMAT].freeze
10
+
9
11
  autoload :Herb, "reactionview/template/handlers/herb/herb"
10
12
 
11
13
  def call(template, source)
14
+ if skip_external_template?(template)
15
+ @erb_fallback = true
16
+
17
+ return super
18
+ end
19
+
12
20
  return super unless intercept_template?(template)
13
21
 
14
22
  ::ReActionView::Template::Handlers::Herb.call(
@@ -19,13 +27,29 @@ module ReActionView
19
27
 
20
28
  log_external_template_error(template, e)
21
29
 
30
+ @erb_fallback = true
31
+
22
32
  super
23
33
  end
24
34
 
25
35
  private
26
36
 
37
+ def implementation_for(template)
38
+ return self.class.erb_implementation if @erb_fallback
39
+
40
+ super
41
+ end
42
+
43
+ def skip_external_template?(template)
44
+ return false unless INTERCEPTED_FORMATS.include?(template.format) && ReActionView.config.intercept_erb
45
+ return false if framework_template?(template) || local_template?(template)
46
+
47
+ ReActionView.config.external_template_mode == :skip
48
+ end
49
+
27
50
  def intercept_template?(template)
28
- return false unless template.format == :html && ReActionView.config.intercept_erb
51
+ return false unless INTERCEPTED_FORMATS.include?(template.format) && ReActionView.config.intercept_erb
52
+ return false if framework_template?(template)
29
53
 
30
54
  local_template?(template) || ReActionView.config.external_template_mode != :skip
31
55
  end
@@ -1,89 +1,101 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "herb"
4
+ require "herb/engine"
5
+
6
+ begin
7
+ require "action_view/template/handlers/erb/herb"
8
+ rescue LoadError
9
+ # Rails 8.1 and earlier ship no builtin Herb implementation
10
+ end
4
11
 
5
12
  module ReActionView
6
13
  class Template
7
14
  module Handlers
8
15
  class Herb
9
- class Herb < ::Herb::Engine
10
- def initialize(input, properties = {})
11
- @newline_pending = 0
16
+ if defined?(::ActionView::Template::Handlers::ERB::Herb)
17
+ class Herb < ::ActionView::Template::Handlers::ERB::Herb
18
+ end
19
+ else
20
+ class Herb < ::Herb::Engine
21
+ def initialize(input, properties = {})
22
+ @newline_pending = 0
12
23
 
13
- # Dup properties so that we don't modify argument
14
- properties = properties.to_h
24
+ # Dup properties so that we don't modify argument
25
+ properties = properties.to_h
15
26
 
16
- properties[:bufvar] ||= "@output_buffer"
17
- properties[:preamble] ||= ""
18
- properties[:postamble] ||= properties[:bufvar].to_s
27
+ properties[:bufvar] ||= "@output_buffer"
28
+ properties[:preamble] ||= ""
29
+ properties[:postamble] ||= properties[:bufvar].to_s
19
30
 
20
- # Tell Herb whether the template will be compiled with `frozen_string_literal: true`
21
- properties[:freeze_template_literals] = !::ActionView::Template.frozen_string_literal
31
+ # Tell Herb whether the template will be compiled with `frozen_string_literal: true`
32
+ properties[:freeze_template_literals] = !::ActionView::Template.frozen_string_literal
22
33
 
23
- # Disable all Herb escape functions - let ActionView::OutputBuffer handle escaping
24
- properties[:escapefunc] = ""
25
- properties[:attrfunc] = nil
26
- properties[:jsfunc] = nil
27
- properties[:cssfunc] = nil
34
+ # Disable all Herb escape functions - let ActionView::OutputBuffer handle escaping
35
+ properties[:escapefunc] = ""
36
+ properties[:attrfunc] = nil
37
+ properties[:jsfunc] = nil
38
+ properties[:cssfunc] = nil
28
39
 
29
- super
30
- end
40
+ super
41
+ end
31
42
 
32
- private
43
+ private
33
44
 
34
- def add_text(text)
35
- return if text.empty?
45
+ def add_text(text)
46
+ return if text.empty?
36
47
 
37
- if text == "\n"
38
- @newline_pending += 1
39
- else
40
- with_buffer do
41
- @src << ".safe_append='"
42
- @src << ("\n" * @newline_pending) if @newline_pending.positive?
43
- @src << text.gsub(/['\\]/, '\\\\\&') << @text_end
44
- end
48
+ if text == "\n"
49
+ @newline_pending += 1
50
+ else
51
+ with_buffer do
52
+ @src << ".safe_append='"
53
+ @src << ("\n" * @newline_pending) if @newline_pending.positive?
54
+ @src << text.gsub(/['\\]/, '\\\\\&') << @text_end
55
+ end
45
56
 
46
- @newline_pending = 0
57
+ @newline_pending = 0
58
+ end
47
59
  end
48
- end
49
60
 
50
- def add_expression(indicator, code)
51
- flush_newline_if_pending(@src)
61
+ def add_expression(indicator, code)
62
+ flush_newline_if_pending(@src)
52
63
 
53
- with_buffer do
54
- @src << expression_append_method(indicator)
64
+ with_buffer do
65
+ @src << expression_append_method(indicator)
55
66
 
56
- if expression_block?
57
- @src << " " << code
58
- else
59
- @src << "(" << code << trailing_newline(code) << ")"
67
+ if expression_block?
68
+ @src << " " << code
69
+ else
70
+ @src << "(" << code << trailing_newline(code) << ")"
71
+ end
60
72
  end
61
73
  end
62
- end
63
74
 
64
- def expression_append_method(indicator)
65
- if (indicator == "==") || @escape
66
- ".safe_expr_append="
67
- else
68
- ".append="
75
+ def expression_append_method(indicator)
76
+ if (indicator == "==") || @escape
77
+ ".safe_expr_append="
78
+ else
79
+ ".append="
80
+ end
69
81
  end
70
- end
71
82
 
72
- def add_code(code)
73
- flush_newline_if_pending(@src)
74
- super
75
- end
83
+ def add_code(code)
84
+ flush_newline_if_pending(@src)
85
+ super
86
+ end
76
87
 
77
- def add_postamble(_)
78
- flush_newline_if_pending(@src)
79
- super
80
- end
88
+ def add_postamble(_)
89
+ flush_newline_if_pending(@src)
90
+ super
91
+ end
81
92
 
82
- def flush_newline_if_pending(src)
83
- return unless @newline_pending.positive?
93
+ def flush_newline_if_pending(src)
94
+ return unless @newline_pending.positive?
84
95
 
85
- with_buffer { src << ".safe_append='#{"\n" * @newline_pending}" << @text_end }
86
- @newline_pending = 0
96
+ with_buffer { src << ".safe_append='#{"\n" * @newline_pending}" << @text_end }
97
+ @newline_pending = 0
98
+ end
87
99
  end
88
100
  end
89
101
  end
@@ -1,5 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "herb"
4
+ require "herb/engine"
5
+ require "herb/engine/slots/dynamics_compiler"
6
+ require "herb/engine/slots/schema_compiler"
7
+
8
+ require "herb/engine/visitors/debug_visitor"
9
+ require "herb/engine/slots/visitor"
10
+ require "herb/engine/slots/dependencies"
11
+ require "herb/engine/visitors/content_for_visitor"
12
+ require "herb/engine/validators"
13
+
3
14
  module ReActionView
4
15
  class Template
5
16
  module Handlers
@@ -10,33 +21,188 @@ module ReActionView
10
21
 
11
22
  class_attribute :erb_implementation, default: Handlers::Herb::Herb
12
23
 
24
+ VALUES_ESCAPING = {
25
+ escape: true,
26
+ escapefunc: "::ERB::Util.h",
27
+ attrfunc: nil,
28
+ jsfunc: nil,
29
+ cssfunc: nil,
30
+ }.freeze
31
+
13
32
  def self.call(template, source, validation_mode: nil)
14
33
  new.call(template, source, validation_mode: validation_mode)
15
34
  end
16
35
 
36
+ def self.compile_for_dependencies(source, path)
37
+ new.compile_for_dependencies(source, path)
38
+ end
39
+
40
+ def self.compile_for_schema(source, path)
41
+ new.compile_for_schema(source, path)
42
+ end
43
+
44
+ def self.compile_for_block(source, path, index)
45
+ new.compile_for_block(source, path, index)
46
+ end
47
+
17
48
  def call(template, source, validation_mode: nil)
18
- visitors = []
49
+ mode = validation_mode || ReActionView.config.validation_mode
19
50
 
20
- if ::ReActionView.config.debug_mode_enabled? && local_template?(template)
21
- visitors << ::Herb::Engine::DebugVisitor.new(
22
- file_path: translate_path_for_editor(template.identifier),
23
- project_path: ::ReActionView.config.project_path
24
- )
25
- end
51
+ visitors = base_visitors(template, validation_mode: mode)
26
52
 
27
53
  config = {
28
- filename: template.identifier,
29
- project_path: Rails.root.to_s,
30
- validation_mode: validation_mode || ReActionView.config.validation_mode,
31
- content_for_head: reactionview_dev_tools_markup(template),
32
- visitors: visitors + ReActionView.config.transform_visitors,
54
+ filename: translate_path_for_editor(template.identifier),
55
+ project_path: ::ReActionView.config.project_path,
56
+ visitors: visitors,
57
+ **engine_options,
33
58
  }
34
59
 
60
+ return values_source(template, source, config) if values_format?(template)
61
+
62
+ slots = slot_visitors(template, source)
63
+
64
+ config[:visitors] = compile_stack(template, validation_mode: mode, slots: slots)
65
+
35
66
  erb_implementation.new(source, config).src
36
67
  end
37
68
 
69
+ def compile_for_dependencies(source, path)
70
+ template = ::Struct.new(:identifier, :format).new(path, :html)
71
+ visitor = slot_visitors(template, source, mark: false).first
72
+
73
+ return nil unless visitor
74
+
75
+ erb_implementation.new(
76
+ source,
77
+ filename: translate_path_for_editor(path),
78
+ project_path: ::ReActionView.config.project_path,
79
+ visitors: [*base_visitors(template), visitor],
80
+ **engine_options
81
+ ).src
82
+
83
+ visitor
84
+ end
85
+
86
+ def compile_for_block(source, path, index)
87
+ template = ::Struct.new(:identifier, :format).new(path, :html)
88
+ visitor = slot_visitors(template, source, mark: false).first
89
+
90
+ return nil unless visitor
91
+
92
+ ::Herb::Engine::Slots::DynamicsCompiler.new(
93
+ source,
94
+ filename: translate_path_for_editor(path),
95
+ project_path: ::ReActionView.config.project_path,
96
+ visitors: base_visitors(template),
97
+ slot_visitor: visitor,
98
+ block: index,
99
+ **engine_options,
100
+ **VALUES_ESCAPING
101
+ ).src
102
+ end
103
+
104
+ def compile_for_schema(source, path)
105
+ template = ::Struct.new(:identifier, :format).new(path, :html)
106
+
107
+ ::Herb::Engine::Slots::SchemaCompiler.call(
108
+ source,
109
+ filename: translate_path_for_editor(path),
110
+ mode: ::ReActionView.config.slot_mode_for(source),
111
+ visitors: -> { base_visitors(template, validation_mode: schema_validation_mode) },
112
+ engine: erb_implementation,
113
+ options: { project_path: ::ReActionView.config.project_path, **engine_options }
114
+ )
115
+ end
116
+
38
117
  private
39
118
 
119
+ def base_visitors(template, validation_mode: ReActionView.config.validation_mode)
120
+ compile_stack(template, validation_mode: validation_mode).reject { |visitor| rewrites_erb_source?(visitor) }
121
+ end
122
+
123
+ def compile_stack(template, validation_mode:, slots: [])
124
+ visitors = [
125
+ *validation_visitors(validation_mode),
126
+ *debug_visitors(template),
127
+ *head_visitors(template),
128
+ *slots,
129
+ *::ReActionView.config.engine.visitors
130
+ ]
131
+
132
+ ::Herb::Visitor::Stack.arrange(visitors)
133
+ end
134
+
135
+ def engine_options
136
+ configured = ::ReActionView.config.engine.parser_options
137
+ options = resolver_option
138
+
139
+ return options if configured.empty?
140
+
141
+ defaults = ::Herb.configuration.engine_option("parser_options", {})
142
+
143
+ options.merge(parser_options: defaults.transform_keys(&:to_sym).merge(configured))
144
+ end
145
+
146
+ def resolver_option
147
+ return {} unless defined?(::Herb::Analysis::PartialResolver)
148
+
149
+ { resolver: ::ReActionView::Template::PartialResolver.current }
150
+ end
151
+
152
+ def rewrites_erb_source?(visitor)
153
+ klass = visitor.class
154
+
155
+ klass.respond_to?(:rewrites_erb_source?) && klass.rewrites_erb_source?
156
+ end
157
+
158
+ def schema_validation_mode
159
+ ReActionView.config.validation_mode == :none ? :none : :overlay
160
+ end
161
+
162
+ def values_source(template, source, config)
163
+ visitor = slot_visitors(template, source, mark: false).first
164
+
165
+ return "{}" unless visitor
166
+
167
+ ::Herb::Engine::Slots::DynamicsCompiler.new(source, config.merge(slot_visitor: visitor, **VALUES_ESCAPING)).src
168
+ end
169
+
170
+ def values_format?(template)
171
+ template.respond_to?(:format) && template.format == :slots
172
+ end
173
+
174
+ def validation_visitors(mode)
175
+ case mode
176
+ when :raise then ::Herb::Engine::Validators.all(fatal: true).to_a
177
+ when :overlay then ::Herb::Engine::Validators.all(fatal: false).to_a
178
+ else []
179
+ end
180
+ end
181
+
182
+ def debug_visitors(template)
183
+ return [] unless ::ReActionView.config.debug_mode_enabled? && local_template?(template)
184
+
185
+ [::Herb::Engine::DebugVisitor.new]
186
+ end
187
+
188
+ def head_visitors(template)
189
+ markup = reactionview_dev_tools_markup(template)
190
+
191
+ return [] if markup.nil? || markup.empty?
192
+
193
+ [::Herb::Engine::ContentForVisitor.new(markup, tag_name: "head")]
194
+ end
195
+
196
+ def slot_visitors(template, source, mark: true)
197
+ return [] unless values_format?(template) || (template.respond_to?(:format) && template.format == :html)
198
+
199
+ mode = ::ReActionView.config.slot_mode_for(source)
200
+
201
+ return [] unless mode
202
+
203
+ [::Herb::Engine::Slots::Visitor.new(mode: mode, mark: mark)]
204
+ end
205
+
40
206
  def layout_template?(template)
41
207
  return false unless template.respond_to?(:identifier) && template.identifier
42
208
 
@@ -76,9 +242,9 @@ module ReActionView
76
242
  end
77
243
 
78
244
  def reactionview_dev_tools_markup(template)
79
- return nil unless ::ReActionView.config.debug_mode_enabled?
80
245
  return nil unless layout_template?(template)
81
246
  return nil unless local_template?(template)
247
+ return slots_meta_markup unless ::ReActionView.config.debug_mode_enabled?
82
248
 
83
249
  <<~HTML
84
250
  <meta name="herb-debug-mode" content="true">
@@ -91,6 +257,15 @@ module ReActionView
91
257
  HTML
92
258
  end
93
259
 
260
+ def slots_meta_markup
261
+ return nil unless ::ReActionView.config.slots && ::ReActionView.config.development?
262
+
263
+ <<~HTML
264
+ <meta name="herb-project-path" content="#{Rails.root}">
265
+ #{dev_server_port_meta_tag}
266
+ HTML
267
+ end
268
+
94
269
  def dismiss_hint_template
95
270
  return unless ::ReActionView.config.validation_mode == :overlay
96
271