hanami-view 1.3.3 → 2.0.0.alpha2

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -10
  3. data/LICENSE +20 -0
  4. data/README.md +20 -862
  5. data/hanami-view.gemspec +26 -16
  6. data/lib/hanami-view.rb +3 -1
  7. data/lib/hanami/view.rb +208 -223
  8. data/lib/hanami/view/application_configuration.rb +77 -0
  9. data/lib/hanami/view/application_context.rb +35 -0
  10. data/lib/hanami/view/application_view.rb +89 -0
  11. data/lib/hanami/view/context.rb +97 -0
  12. data/lib/hanami/view/context_helpers/content_helpers.rb +26 -0
  13. data/lib/hanami/view/decorated_attributes.rb +82 -0
  14. data/lib/hanami/view/errors.rb +19 -56
  15. data/lib/hanami/view/exposure.rb +126 -0
  16. data/lib/hanami/view/exposures.rb +74 -0
  17. data/lib/hanami/view/part.rb +217 -0
  18. data/lib/hanami/view/part_builder.rb +140 -0
  19. data/lib/hanami/view/path.rb +68 -0
  20. data/lib/hanami/view/render_environment.rb +62 -0
  21. data/lib/hanami/view/render_environment_missing.rb +44 -0
  22. data/lib/hanami/view/rendered.rb +55 -0
  23. data/lib/hanami/view/renderer.rb +79 -0
  24. data/lib/hanami/view/scope.rb +189 -0
  25. data/lib/hanami/view/scope_builder.rb +98 -0
  26. data/lib/hanami/view/standalone_view.rb +396 -0
  27. data/lib/hanami/view/tilt.rb +78 -0
  28. data/lib/hanami/view/tilt/erb.rb +26 -0
  29. data/lib/hanami/view/tilt/erbse.rb +21 -0
  30. data/lib/hanami/view/tilt/haml.rb +26 -0
  31. data/lib/hanami/view/version.rb +5 -5
  32. metadata +114 -70
  33. data/LICENSE.md +0 -22
  34. data/lib/hanami/layout.rb +0 -190
  35. data/lib/hanami/presenter.rb +0 -98
  36. data/lib/hanami/view/configuration.rb +0 -504
  37. data/lib/hanami/view/dsl.rb +0 -347
  38. data/lib/hanami/view/escape.rb +0 -225
  39. data/lib/hanami/view/inheritable.rb +0 -54
  40. data/lib/hanami/view/rendering.rb +0 -294
  41. data/lib/hanami/view/rendering/layout_finder.rb +0 -128
  42. data/lib/hanami/view/rendering/layout_registry.rb +0 -69
  43. data/lib/hanami/view/rendering/layout_scope.rb +0 -281
  44. data/lib/hanami/view/rendering/null_layout.rb +0 -52
  45. data/lib/hanami/view/rendering/null_local.rb +0 -82
  46. data/lib/hanami/view/rendering/null_template.rb +0 -83
  47. data/lib/hanami/view/rendering/null_view.rb +0 -26
  48. data/lib/hanami/view/rendering/options.rb +0 -24
  49. data/lib/hanami/view/rendering/partial.rb +0 -31
  50. data/lib/hanami/view/rendering/partial_file.rb +0 -29
  51. data/lib/hanami/view/rendering/partial_finder.rb +0 -75
  52. data/lib/hanami/view/rendering/partial_templates_finder.rb +0 -73
  53. data/lib/hanami/view/rendering/registry.rb +0 -134
  54. data/lib/hanami/view/rendering/scope.rb +0 -108
  55. data/lib/hanami/view/rendering/subscope.rb +0 -56
  56. data/lib/hanami/view/rendering/template.rb +0 -69
  57. data/lib/hanami/view/rendering/template_finder.rb +0 -55
  58. data/lib/hanami/view/rendering/template_name.rb +0 -50
  59. data/lib/hanami/view/rendering/templates_finder.rb +0 -144
  60. data/lib/hanami/view/rendering/view_finder.rb +0 -37
  61. data/lib/hanami/view/template.rb +0 -57
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
4
+
5
+ module Hanami
6
+ class View
7
+ # An exposure defined on a view
8
+ #
9
+ # @api private
10
+ class Exposure
11
+ include Dry::Equalizer(:name, :proc, :object, :options)
12
+
13
+ EXPOSURE_DEPENDENCY_PARAMETER_TYPES = %i[req opt].freeze
14
+ INPUT_PARAMETER_TYPES = %i[key keyreq keyrest].freeze
15
+
16
+ attr_reader :name
17
+ attr_reader :proc
18
+ attr_reader :object
19
+ attr_reader :options
20
+
21
+ def initialize(name, proc = nil, object = nil, **options)
22
+ @name = name
23
+ @proc = prepare_proc(proc, object)
24
+ @object = object
25
+ @options = options
26
+ end
27
+
28
+ def bind(obj)
29
+ self.class.new(name, proc, obj, **options)
30
+ end
31
+
32
+ def dependency_names
33
+ if proc
34
+ proc.parameters.each_with_object([]) { |(type, name), names|
35
+ names << name if EXPOSURE_DEPENDENCY_PARAMETER_TYPES.include?(type)
36
+ }
37
+ else
38
+ []
39
+ end
40
+ end
41
+
42
+ def input_keys
43
+ if proc
44
+ proc.parameters.each_with_object([]) { |(type, name), keys|
45
+ keys << name if INPUT_PARAMETER_TYPES.include?(type)
46
+ }
47
+ else
48
+ []
49
+ end
50
+ end
51
+
52
+ def for_layout?
53
+ options.fetch(:layout) { false }
54
+ end
55
+
56
+ def decorate?
57
+ options.fetch(:decorate) { true }
58
+ end
59
+
60
+ def private?
61
+ options.fetch(:private) { false }
62
+ end
63
+
64
+ def default_value
65
+ options[:default]
66
+ end
67
+
68
+ def call(input, locals = {})
69
+ if proc
70
+ call_proc(input, locals)
71
+ else
72
+ input.fetch(name) { default_value }
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def call_proc(input, locals)
79
+ args, keywords = proc_args(input, locals)
80
+
81
+ if keywords.empty?
82
+ if proc.is_a?(Method)
83
+ proc.(*args)
84
+ else
85
+ object.instance_exec(*args, &proc)
86
+ end
87
+ else
88
+ if proc.is_a?(Method)
89
+ proc.(*args, **keywords)
90
+ else
91
+ object.instance_exec(*args, **keywords, &proc)
92
+ end
93
+ end
94
+ end
95
+
96
+ def proc_args(input, locals)
97
+ dependency_args = proc_dependency_args(locals)
98
+ keywords = proc_input_args(input)
99
+
100
+ if keywords.empty?
101
+ [dependency_args, {}]
102
+ else
103
+ [dependency_args, keywords]
104
+ end
105
+ end
106
+
107
+ def proc_dependency_args(locals)
108
+ dependency_names.map { |name| locals.fetch(name) }
109
+ end
110
+
111
+ def proc_input_args(input)
112
+ input_keys.each_with_object({}) { |key, args|
113
+ args[key] = input[key] if input.key?(key)
114
+ }
115
+ end
116
+
117
+ def prepare_proc(proc, object)
118
+ if proc
119
+ proc
120
+ elsif object.respond_to?(name, _include_private = true)
121
+ object.method(name)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tsort"
4
+ require "dry/core/equalizer"
5
+ require_relative "exposure"
6
+
7
+ module Hanami
8
+ class View
9
+ # @api private
10
+ class Exposures
11
+ include Dry::Equalizer(:exposures)
12
+ include TSort
13
+
14
+ attr_reader :exposures
15
+
16
+ def initialize(exposures = {})
17
+ @exposures = exposures
18
+ end
19
+
20
+ def key?(name)
21
+ exposures.key?(name)
22
+ end
23
+
24
+ def [](name)
25
+ exposures[name]
26
+ end
27
+
28
+ def each(&block)
29
+ exposures.each(&block)
30
+ end
31
+
32
+ def add(name, proc = nil, **options)
33
+ exposures[name] = Exposure.new(name, proc, **options)
34
+ end
35
+
36
+ def import(name, exposure)
37
+ exposures[name] = exposure.dup
38
+ end
39
+
40
+ def bind(obj)
41
+ bound_exposures = exposures.each_with_object({}) { |(name, exposure), memo|
42
+ memo[name] = exposure.bind(obj)
43
+ }
44
+
45
+ self.class.new(bound_exposures)
46
+ end
47
+
48
+ def call(input)
49
+ # rubocop:disable Style/MultilineBlockChain
50
+ tsort.each_with_object({}) { |name, memo|
51
+ next unless (exposure = self[name])
52
+
53
+ value = exposure.(input, memo)
54
+ value = yield(value, exposure) if block_given?
55
+
56
+ memo[name] = value
57
+ }.each_with_object({}) { |(name, value), memo|
58
+ memo[name] = value unless self[name].private?
59
+ }
60
+ # rubocop:enable Style/MultilineBlockChain
61
+ end
62
+
63
+ private
64
+
65
+ def tsort_each_node(&block)
66
+ exposures.each_key(&block)
67
+ end
68
+
69
+ def tsort_each_child(name, &block)
70
+ self[name].dependency_names.each(&block) if exposures.key?(name)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
4
+ require_relative "decorated_attributes"
5
+ require_relative "render_environment_missing"
6
+
7
+ module Hanami
8
+ class View
9
+ # Decorates an exposure value and provides a place to encapsulate
10
+ # view-specific behavior alongside your application's domain objects.
11
+ #
12
+ # @abstract Subclass this and provide your own methods adding view-specific
13
+ # behavior. You should not override `#initialize`.
14
+ #
15
+ # @see https://dry-rb.org/gems/dry-view/parts/
16
+ #
17
+ # @api public
18
+ class Part
19
+ # @api private
20
+ CONVENIENCE_METHODS = %i[
21
+ format
22
+ context
23
+ render
24
+ scope
25
+ value
26
+ ].freeze
27
+
28
+ include Dry::Equalizer(:_name, :_value, :_render_env)
29
+ include DecoratedAttributes
30
+
31
+ # The part's name. This comes from the exposure supplying the value.
32
+ #
33
+ # @return [Symbol] name
34
+ #
35
+ # @api public
36
+ attr_reader :_name
37
+
38
+ # The decorated value. This is the value returned from the exposure.
39
+ #
40
+ # @overload _value
41
+ # Returns the value.
42
+ # @overload value
43
+ # A convenience alias for `_value`. Is available unless the value itself
44
+ # responds to `#value`.
45
+ #
46
+ # @return [Object] value
47
+ #
48
+ # @api public
49
+ attr_reader :_value
50
+
51
+ # The current render environment
52
+ #
53
+ # @return [RenderEnvironment] render environment
54
+ #
55
+ # @api private
56
+ attr_reader :_render_env
57
+
58
+ # Determins a part name (when initialized without one). Intended for use
59
+ # only while unit testing Parts.
60
+ #
61
+ # @api private
62
+ def self.part_name(inflector)
63
+ name ? inflector.underscore(inflector.demodulize(name)) : "part"
64
+ end
65
+
66
+ # Returns a new Part instance
67
+ #
68
+ # @param name [Symbol] part name
69
+ # @param value [Object] the value to decorate
70
+ # @param render_env [RenderEnvironment] render environment
71
+ #
72
+ # @api public
73
+ def initialize(
74
+ render_env: RenderEnvironmentMissing.new,
75
+ name: self.class.part_name(render_env.inflector),
76
+ value:
77
+ )
78
+ @_name = name
79
+ @_value = value
80
+ @_render_env = render_env
81
+ end
82
+
83
+ # The template format for the current render environment.
84
+ #
85
+ # @overload _format
86
+ # Returns the format.
87
+ # @overload format
88
+ # A convenience alias for `#_format.` Is available unless the value
89
+ # itself responds to `#format`.
90
+ #
91
+ # @return [Symbol] format
92
+ #
93
+ # @api public
94
+ def _format
95
+ _render_env.format
96
+ end
97
+
98
+ # The context object for the current render environment
99
+ #
100
+ # @overload _context
101
+ # Returns the context.
102
+ # @overload context
103
+ # A convenience alias for `#_context`. Is available unless the value
104
+ # itself responds to `#context`.
105
+ #
106
+ # @return [Context] context
107
+ #
108
+ # @api public
109
+ def _context
110
+ _render_env.context
111
+ end
112
+
113
+ # Renders a new partial with the part included in its locals.
114
+ #
115
+ # @overload _render(partial_name, as: _name, **locals, &block)
116
+ # Renders the partial.
117
+ # @overload render(partial_name, as: _name, **locals, &block)
118
+ # A convenience alias for `#_render`. Is available unless the value
119
+ # itself responds to `#render`.
120
+ #
121
+ # @param partial_name [Symbol, String] partial name
122
+ # @param as [Symbol] the name for the Part to assume in the partial's locals. Defaults to
123
+ # the Part's `_name`.
124
+ # @param locals [Hash<Symbol, Object>] other locals to provide the partial
125
+ #
126
+ # @return [String] rendered partial
127
+ #
128
+ # @api public
129
+ # rubocop:disable Naming/UncommunicativeMethodParamName
130
+ def _render(partial_name, as: _name, **locals, &block)
131
+ _render_env.partial(partial_name, _render_env.scope({as => self}.merge(locals)), &block)
132
+ end
133
+ # rubocop:enable Naming/UncommunicativeMethodParamName
134
+
135
+ # Builds a new scope with the part included in its locals.
136
+ #
137
+ # @overload _scope(scope_name = nil, **locals)
138
+ # Builds the scope.
139
+ # @overload scope(scope_name = nil, **locals)
140
+ # A convenience alias for `#_scope`. Is available unless the value
141
+ # itself responds to `#scope`.
142
+ #
143
+ # @param scope_name [Symbol, nil] scope name, used by the scope builder to determine the
144
+ # scope class
145
+ # @param locals [Hash<Symbol, Object>] other locals to provide the partial
146
+ #
147
+ # @return [Hanami::View::Scope] scope
148
+ #
149
+ # @api public
150
+ def _scope(scope_name = nil, **locals)
151
+ _render_env.scope(scope_name, {_name => self}.merge(locals))
152
+ end
153
+
154
+ # Returns a string representation of the value
155
+ #
156
+ # @return [String]
157
+ #
158
+ # @api public
159
+ def to_s
160
+ _value.to_s
161
+ end
162
+
163
+ # Builds a new a part with the given parameters
164
+ #
165
+ # This is helpful for manually constructing a new part object that
166
+ # maintains the current render environment.
167
+ #
168
+ # However, using `.decorate` is preferred for declaring attributes that
169
+ # should also be decorated as parts.
170
+ #
171
+ # @see DecoratedAttributes::ClassInterface#decorate
172
+ #
173
+ # @param klass [Class] part class to use (defaults to the part's class)
174
+ # @param name [Symbol] part name (defaults to the part's name)
175
+ # @param value [Object] value to decorate (defaults to the part's value)
176
+ # @param options[Hash<Symbol, Object>] other options to provide when initializing the new part
177
+ #
178
+ # @api public
179
+ def new(klass = self.class, name: _name, value: _value, **options)
180
+ klass.new(
181
+ name: name,
182
+ value: value,
183
+ render_env: _render_env,
184
+ **options
185
+ )
186
+ end
187
+
188
+ # Returns a string representation of the part
189
+ #
190
+ # @return [String]
191
+ #
192
+ # @api public
193
+ def inspect
194
+ %(#<#{self.class.name} name=#{_name.inspect} value=#{_value.inspect}>)
195
+ end
196
+
197
+ private
198
+
199
+ # Handles missing methods. If the `_value` responds to the method, then
200
+ # the method will be sent to the value.
201
+ def method_missing(name, *args, &block)
202
+ if _value.respond_to?(name)
203
+ _value.public_send(name, *args, &block)
204
+ elsif CONVENIENCE_METHODS.include?(name)
205
+ __send__(:"_#{name}", *args, &block)
206
+ else
207
+ super
208
+ end
209
+ end
210
+ ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
211
+
212
+ def respond_to_missing?(name, include_private = false)
213
+ CONVENIENCE_METHODS.include?(name) || _value.respond_to?(name, include_private) || super
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/cache"
4
+ require "dry/core/equalizer"
5
+ require_relative "part"
6
+
7
+ module Hanami
8
+ class View
9
+ # Decorates exposure values with matching parts
10
+ #
11
+ # @api private
12
+ class PartBuilder
13
+ extend Dry::Core::Cache
14
+ include Dry::Equalizer(:namespace)
15
+
16
+ attr_reader :namespace
17
+ attr_reader :render_env
18
+
19
+ # Returns a new instance of PartBuilder
20
+ #
21
+ # @api private
22
+ def initialize(namespace: nil, render_env: nil)
23
+ @namespace = namespace
24
+ @render_env = render_env
25
+ end
26
+
27
+ # @api private
28
+ def for_render_env(render_env)
29
+ return self if render_env == self.render_env
30
+
31
+ self.class.new(namespace: namespace, render_env: render_env)
32
+ end
33
+
34
+ # Decorates an exposure value
35
+ #
36
+ # @param name [Symbol] exposure name
37
+ # @param value [Object] exposure value
38
+ # @param options [Hash] exposure options
39
+ #
40
+ # @return [Hanami::View::Part] decorated value
41
+ #
42
+ # @api private
43
+ def call(name, value, **options)
44
+ builder = value.respond_to?(:to_ary) ? :build_collection_part : :build_part
45
+
46
+ send(builder, name, value, **options)
47
+ end
48
+
49
+ private
50
+
51
+ def build_part(name, value, **options)
52
+ klass = part_class(name: name, **options)
53
+
54
+ klass.new(
55
+ name: name,
56
+ value: value,
57
+ render_env: render_env
58
+ )
59
+ end
60
+
61
+ def build_collection_part(name, value, **options)
62
+ collection_as = collection_options(name: name, **options)[:as]
63
+ item_name, item_as = collection_item_options(name: name, **options).values_at(:name, :as)
64
+
65
+ arr = value.to_ary.map { |obj|
66
+ build_part(item_name, obj, **options.merge(as: item_as))
67
+ }
68
+
69
+ build_part(name, arr, **options.merge(as: collection_as))
70
+ end
71
+
72
+ # rubocop:disable Lint/UnusedMethodArgument
73
+ def collection_options(name:, **options)
74
+ collection_as = options[:as].is_a?(Array) ? options[:as].first : nil
75
+
76
+ options.merge(as: collection_as)
77
+ end
78
+ # rubocop:enable Lint/UnusedMethodArgument
79
+
80
+ def collection_item_options(name:, **options)
81
+ singular_name = inflector.singularize(name).to_sym
82
+ singular_as =
83
+ if options[:as].is_a?(Array)
84
+ options[:as].last if options[:as].length > 1
85
+ else
86
+ options[:as]
87
+ end
88
+
89
+ if singular_as && !singular_as.is_a?(Class)
90
+ singular_as = inflector.singularize(singular_as.to_s)
91
+ end
92
+
93
+ options.merge(
94
+ name: singular_name,
95
+ as: singular_as
96
+ )
97
+ end
98
+
99
+ def part_class(name:, fallback_class: Part, **options)
100
+ name = options[:as] || name
101
+
102
+ if name.is_a?(Class)
103
+ name
104
+ else
105
+ fetch_or_store(namespace, name, fallback_class) do
106
+ resolve_part_class(name: name, fallback_class: fallback_class)
107
+ end
108
+ end
109
+ end
110
+
111
+ # rubocop:disable Metrics/PerceivedComplexity
112
+ def resolve_part_class(name:, fallback_class:)
113
+ return fallback_class unless namespace
114
+
115
+ name = inflector.camelize(name.to_s)
116
+
117
+ # Give autoloaders a chance to act
118
+ begin
119
+ klass = namespace.const_get(name)
120
+ rescue NameError # rubocop:disable Lint/HandleExceptions
121
+ end
122
+
123
+ if !klass && namespace.const_defined?(name, false)
124
+ klass = namespace.const_get(name)
125
+ end
126
+
127
+ if klass && klass < Part
128
+ klass
129
+ else
130
+ fallback_class
131
+ end
132
+ end
133
+ # rubocop:enable Metrics/PerceivedComplexity
134
+
135
+ def inflector
136
+ render_env.inflector
137
+ end
138
+ end
139
+ end
140
+ end