hanami-view 1.3.1 → 2.0.0.alpha3

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 +11 -0
  3. data/LICENSE +20 -0
  4. data/README.md +17 -835
  5. data/hanami-view.gemspec +26 -16
  6. data/lib/hanami/view/application_configuration.rb +77 -0
  7. data/lib/hanami/view/application_context.rb +35 -0
  8. data/lib/hanami/view/application_view.rb +89 -0
  9. data/lib/hanami/view/context.rb +97 -0
  10. data/lib/hanami/view/context_helpers/content_helpers.rb +26 -0
  11. data/lib/hanami/view/decorated_attributes.rb +82 -0
  12. data/lib/hanami/view/errors.rb +31 -53
  13. data/lib/hanami/view/exposure.rb +126 -0
  14. data/lib/hanami/view/exposures.rb +74 -0
  15. data/lib/hanami/view/part.rb +217 -0
  16. data/lib/hanami/view/part_builder.rb +140 -0
  17. data/lib/hanami/view/path.rb +68 -0
  18. data/lib/hanami/view/render_environment.rb +62 -0
  19. data/lib/hanami/view/render_environment_missing.rb +44 -0
  20. data/lib/hanami/view/rendered.rb +55 -0
  21. data/lib/hanami/view/renderer.rb +79 -0
  22. data/lib/hanami/view/scope.rb +189 -0
  23. data/lib/hanami/view/scope_builder.rb +98 -0
  24. data/lib/hanami/view/standalone_view.rb +400 -0
  25. data/lib/hanami/view/tilt/erb.rb +26 -0
  26. data/lib/hanami/view/tilt/erbse.rb +21 -0
  27. data/lib/hanami/view/tilt/haml.rb +26 -0
  28. data/lib/hanami/view/tilt.rb +78 -0
  29. data/lib/hanami/view/version.rb +5 -5
  30. data/lib/hanami/view.rb +208 -223
  31. data/lib/hanami-view.rb +3 -1
  32. metadata +120 -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/layout_finder.rb +0 -128
  41. data/lib/hanami/view/rendering/layout_registry.rb +0 -69
  42. data/lib/hanami/view/rendering/layout_scope.rb +0 -274
  43. data/lib/hanami/view/rendering/null_layout.rb +0 -52
  44. data/lib/hanami/view/rendering/null_local.rb +0 -82
  45. data/lib/hanami/view/rendering/null_template.rb +0 -83
  46. data/lib/hanami/view/rendering/null_view.rb +0 -26
  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/rendering.rb +0 -294
  61. data/lib/hanami/view/template.rb +0 -57
@@ -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
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "dry/core/cache"
5
+
6
+ module Hanami
7
+ class View
8
+ # @api private
9
+ class Path
10
+ extend Dry::Core::Cache
11
+ include Dry::Equalizer(:dir, :root)
12
+
13
+ attr_reader :dir, :root
14
+
15
+ def self.[](path)
16
+ if path.is_a?(self)
17
+ path
18
+ else
19
+ new(path)
20
+ end
21
+ end
22
+
23
+ def initialize(dir, root: dir)
24
+ @dir = Pathname(dir)
25
+ @root = Pathname(root)
26
+ end
27
+
28
+ def lookup(name, format, child_dirs: [], parent_dir: false)
29
+ fetch_or_store(dir, root, name, format, child_dirs, parent_dir) do
30
+ lookup_template(name, format) ||
31
+ lookup_in_child_dirs(name, format, child_dirs: child_dirs) ||
32
+ parent_dir && lookup_in_parent_dir(name, format, child_dirs: child_dirs)
33
+ end
34
+ end
35
+
36
+ def chdir(dirname)
37
+ self.class.new(dir.join(dirname), root: root)
38
+ end
39
+
40
+ def to_s
41
+ dir.to_s
42
+ end
43
+
44
+ private
45
+
46
+ def root?
47
+ dir == root
48
+ end
49
+
50
+ # Search for a template using a wildcard for the engine extension
51
+ def lookup_template(name, format)
52
+ glob = dir.join("#{name}.#{format}.*")
53
+ Dir[glob].first
54
+ end
55
+
56
+ def lookup_in_child_dirs(name, format, child_dirs:)
57
+ child_dirs.reduce(nil) { |_, dir|
58
+ template = chdir(dir).lookup(name, format)
59
+ break template if template
60
+ }
61
+ end
62
+
63
+ def lookup_in_parent_dir(name, format, child_dirs:)
64
+ !root? && chdir("..").lookup(name, format, child_dirs: child_dirs, parent_dir: true)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
4
+
5
+ module Hanami
6
+ class View
7
+ # @api private
8
+ class RenderEnvironment
9
+ def self.prepare(renderer, config, context)
10
+ new(
11
+ renderer: renderer,
12
+ inflector: config.inflector,
13
+ context: context,
14
+ scope_builder: config.scope_builder.new(namespace: config.scope_namespace),
15
+ part_builder: config.part_builder.new(namespace: config.part_namespace)
16
+ )
17
+ end
18
+
19
+ include Dry::Equalizer(:renderer, :inflector, :context, :scope_builder, :part_builder)
20
+
21
+ attr_reader :renderer, :inflector, :context, :scope_builder, :part_builder
22
+
23
+ def initialize(renderer:, inflector:, context:, scope_builder:, part_builder:)
24
+ @renderer = renderer
25
+ @inflector = inflector
26
+ @context = context.for_render_env(self)
27
+ @scope_builder = scope_builder.for_render_env(self)
28
+ @part_builder = part_builder.for_render_env(self)
29
+ end
30
+
31
+ def format
32
+ renderer.format
33
+ end
34
+
35
+ def part(name, value, **options)
36
+ part_builder.(name, value, **options)
37
+ end
38
+
39
+ def scope(name = nil, locals) # rubocop:disable Style/OptionalArguments
40
+ scope_builder.(name, locals)
41
+ end
42
+
43
+ def template(name, scope, &block)
44
+ renderer.template(name, scope, &block)
45
+ end
46
+
47
+ def partial(name, scope, &block)
48
+ renderer.partial(name, scope, &block)
49
+ end
50
+
51
+ def chdir(dirname)
52
+ self.class.new(
53
+ renderer: renderer.chdir(dirname),
54
+ inflector: inflector,
55
+ context: context,
56
+ scope_builder: scope_builder,
57
+ part_builder: part_builder
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/inflector"
4
+
5
+ module Hanami
6
+ class View
7
+ # @api private
8
+ class RenderEnvironmentMissing
9
+ class MissingEnvironmentError < StandardError
10
+ def message
11
+ "a +render_env+ must be provided"
12
+ end
13
+ end
14
+
15
+ def format
16
+ raise MissingEnvironmentError
17
+ end
18
+
19
+ def context
20
+ raise MissingEnvironmentError
21
+ end
22
+
23
+ def part(_name, _value, **_options)
24
+ raise MissingEnvironmentError
25
+ end
26
+
27
+ def scope(_name = nil, _locals) # rubocop:disable Style/OptionalArguments
28
+ raise MissingEnvironmentError
29
+ end
30
+
31
+ def template(_name, _scope)
32
+ raise MissingEnvironmentError
33
+ end
34
+
35
+ def partial(_name, _scope)
36
+ raise MissingEnvironmentError
37
+ end
38
+
39
+ def inflector
40
+ @inflector ||= Dry::Inflector.new
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
4
+
5
+ module Hanami
6
+ class View
7
+ # Output of a View rendering
8
+ #
9
+ # @api public
10
+ class Rendered
11
+ include Dry::Equalizer(:output, :locals)
12
+
13
+ # Returns the rendered view
14
+ #
15
+ # @return [String]
16
+ #
17
+ # @api public
18
+ attr_reader :output
19
+
20
+ # Returns the hash of locals used to render the view
21
+ #
22
+ # @return [Hash[<Symbol, Hanami::View::Part>] locals hash
23
+ #
24
+ # @api public
25
+ attr_reader :locals
26
+
27
+ # @api private
28
+ def initialize(output:, locals:)
29
+ @output = output
30
+ @locals = locals
31
+ end
32
+
33
+ # Returns the local corresponding to the key
34
+ #
35
+ # @param name [Symbol] local key
36
+ #
37
+ # @return [Hanami::View::Part]
38
+ #
39
+ # @api public
40
+ def [](name)
41
+ locals[name]
42
+ end
43
+
44
+ # Returns the rendered view
45
+ #
46
+ # @return [String]
47
+ #
48
+ # @api public
49
+ def to_s
50
+ output
51
+ end
52
+ alias_method :to_str, :to_s
53
+ end
54
+ end
55
+ end