hanami-view 1.3.0.beta1 → 2.0.0.alpha2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  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 +113 -63
  33. data/LICENSE.md +0 -22
  34. data/lib/hanami/layout.rb +0 -172
  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 -274
  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/options.rb +0 -24
  48. data/lib/hanami/view/rendering/partial.rb +0 -31
  49. data/lib/hanami/view/rendering/partial_file.rb +0 -29
  50. data/lib/hanami/view/rendering/partial_finder.rb +0 -75
  51. data/lib/hanami/view/rendering/partial_templates_finder.rb +0 -73
  52. data/lib/hanami/view/rendering/registry.rb +0 -134
  53. data/lib/hanami/view/rendering/scope.rb +0 -108
  54. data/lib/hanami/view/rendering/subscope.rb +0 -56
  55. data/lib/hanami/view/rendering/template.rb +0 -69
  56. data/lib/hanami/view/rendering/template_finder.rb +0 -55
  57. data/lib/hanami/view/rendering/template_name.rb +0 -50
  58. data/lib/hanami/view/rendering/templates_finder.rb +0 -144
  59. data/lib/hanami/view/rendering/view_finder.rb +0 -37
  60. 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