dry-view 0.5.3 → 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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +18 -0
  3. data/.travis.yml +15 -10
  4. data/.yardopts +5 -0
  5. data/CHANGELOG.md +60 -1
  6. data/Gemfile +15 -5
  7. data/README.md +38 -13
  8. data/bin/setup +5 -0
  9. data/bin/setup_helpers.rb +27 -0
  10. data/dry-view.gemspec +8 -9
  11. data/lib/dry-view.rb +3 -1
  12. data/lib/dry/view.rb +503 -2
  13. data/lib/dry/view/context.rb +80 -0
  14. data/lib/dry/view/decorated_attributes.rb +81 -0
  15. data/lib/dry/view/exposure.rb +15 -2
  16. data/lib/dry/view/exposures.rb +15 -5
  17. data/lib/dry/view/part.rb +154 -61
  18. data/lib/dry/view/part_builder.rb +136 -0
  19. data/lib/dry/view/path.rb +22 -5
  20. data/lib/dry/view/render_environment.rb +62 -0
  21. data/lib/dry/view/render_environment_missing.rb +44 -0
  22. data/lib/dry/view/rendered.rb +55 -0
  23. data/lib/dry/view/renderer.rb +22 -19
  24. data/lib/dry/view/scope.rb +146 -14
  25. data/lib/dry/view/scope_builder.rb +98 -0
  26. data/lib/dry/view/tilt.rb +78 -0
  27. data/lib/dry/view/tilt/erb.rb +26 -0
  28. data/lib/dry/view/tilt/erbse.rb +21 -0
  29. data/lib/dry/view/tilt/haml.rb +26 -0
  30. data/lib/dry/view/version.rb +5 -2
  31. metadata +50 -88
  32. data/benchmarks/templates/button.html.erb +0 -1
  33. data/benchmarks/view.rb +0 -24
  34. data/lib/dry/view/controller.rb +0 -159
  35. data/lib/dry/view/decorator.rb +0 -45
  36. data/lib/dry/view/missing_renderer.rb +0 -15
  37. data/spec/fixtures/templates/_hello.html.slim +0 -1
  38. data/spec/fixtures/templates/controller_renderer_options.html.erb +0 -3
  39. data/spec/fixtures/templates/decorated_parts.html.slim +0 -4
  40. data/spec/fixtures/templates/edit.html.slim +0 -11
  41. data/spec/fixtures/templates/empty.html.slim +0 -1
  42. data/spec/fixtures/templates/greeting.html.slim +0 -2
  43. data/spec/fixtures/templates/hello.html.slim +0 -1
  44. data/spec/fixtures/templates/layouts/app.html.slim +0 -6
  45. data/spec/fixtures/templates/layouts/app.txt.erb +0 -3
  46. data/spec/fixtures/templates/parts_with_args.html.slim +0 -3
  47. data/spec/fixtures/templates/parts_with_args/_box.html.slim +0 -3
  48. data/spec/fixtures/templates/shared/_index_table.html.slim +0 -2
  49. data/spec/fixtures/templates/shared/_shared_hello.html.slim +0 -1
  50. data/spec/fixtures/templates/tasks.html.slim +0 -3
  51. data/spec/fixtures/templates/user.html.slim +0 -2
  52. data/spec/fixtures/templates/users.html.slim +0 -5
  53. data/spec/fixtures/templates/users.txt.erb +0 -3
  54. data/spec/fixtures/templates/users/_row.html.slim +0 -2
  55. data/spec/fixtures/templates/users/_tbody.html.slim +0 -5
  56. data/spec/fixtures/templates/users_with_count.html.slim +0 -5
  57. data/spec/fixtures/templates/users_with_count_inherit.html.slim +0 -6
  58. data/spec/fixtures/templates_override/_hello.html.slim +0 -1
  59. data/spec/fixtures/templates_override/users.html.slim +0 -5
  60. data/spec/integration/decorator_spec.rb +0 -80
  61. data/spec/integration/exposures_spec.rb +0 -392
  62. data/spec/integration/part/decorated_attributes_spec.rb +0 -193
  63. data/spec/integration/view_spec.rb +0 -133
  64. data/spec/spec_helper.rb +0 -46
  65. data/spec/unit/controller_spec.rb +0 -83
  66. data/spec/unit/decorator_spec.rb +0 -61
  67. data/spec/unit/exposure_spec.rb +0 -227
  68. data/spec/unit/exposures_spec.rb +0 -103
  69. data/spec/unit/part_spec.rb +0 -104
  70. data/spec/unit/renderer_spec.rb +0 -57
  71. data/spec/unit/scope_spec.rb +0 -53
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/cache"
4
+ require "dry/equalizer"
5
+ require_relative "part"
6
+
7
+ module Dry
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 [Dry::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
+ def collection_options(name:, **options)
73
+ collection_as = options[:as].is_a?(Array) ? options[:as].first : nil
74
+
75
+ options.merge(as: collection_as)
76
+ end
77
+
78
+ def collection_item_options(name:, **options)
79
+ singular_name = inflector.singularize(name).to_sym
80
+ singular_as =
81
+ if options[:as].is_a?(Array)
82
+ options[:as].last if options[:as].length > 1
83
+ else
84
+ options[:as]
85
+ end
86
+
87
+ if singular_as && !singular_as.is_a?(Class)
88
+ singular_as = inflector.singularize(singular_as.to_s)
89
+ end
90
+
91
+ options.merge(
92
+ name: singular_name,
93
+ as: singular_as,
94
+ )
95
+ end
96
+
97
+ def part_class(name:, fallback_class: Part, **options)
98
+ name = options[:as] || name
99
+
100
+ if name.is_a?(Class)
101
+ name
102
+ else
103
+ fetch_or_store(namespace, name, fallback_class) do
104
+ resolve_part_class(name: name, fallback_class: fallback_class)
105
+ end
106
+ end
107
+ end
108
+
109
+ def resolve_part_class(name:, fallback_class:)
110
+ return fallback_class unless namespace
111
+
112
+ name = inflector.camelize(name.to_s)
113
+
114
+ # Give autoloaders a chance to act
115
+ begin
116
+ klass = namespace.const_get(name)
117
+ rescue NameError
118
+ end
119
+
120
+ if !klass && namespace.const_defined?(name, false)
121
+ klass = namespace.const_get(name)
122
+ end
123
+
124
+ if klass && klass < Part
125
+ klass
126
+ else
127
+ fallback_class
128
+ end
129
+ end
130
+
131
+ def inflector
132
+ render_env.inflector
133
+ end
134
+ end
135
+ end
136
+ end
data/lib/dry/view/path.rb CHANGED
@@ -1,19 +1,36 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "pathname"
4
+ require "dry/core/cache"
2
5
 
3
6
  module Dry
4
- module View
7
+ class View
8
+ # @api private
5
9
  class Path
10
+ extend Dry::Core::Cache
6
11
  include Dry::Equalizer(:dir, :root)
7
12
 
8
13
  attr_reader :dir, :root
9
14
 
10
- def initialize(dir, options = {})
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)
11
24
  @dir = Pathname(dir)
12
- @root = Pathname(options.fetch(:root, dir))
25
+ @root = root
13
26
  end
14
27
 
15
- def lookup(name, format)
16
- template?(name, format) || template?("shared/#{name}", format) || !root? && chdir('..').lookup(name, format)
28
+ def lookup(name, format, include_shared: true)
29
+ fetch_or_store(dir, root, name, format) do
30
+ template?(name, format) ||
31
+ (include_shared && template?("shared/#{name}", format)) ||
32
+ !root? && chdir("..").lookup(name, format)
33
+ end
17
34
  end
18
35
 
19
36
  def chdir(dirname)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/equalizer"
4
+
5
+ module Dry
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)
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 Dry
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)
28
+ raise MissingEnvironmentError
29
+ end
30
+
31
+ def template(name, scope, &block)
32
+ raise MissingEnvironmentError
33
+ end
34
+
35
+ def partial(name, scope, &block)
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/equalizer"
4
+
5
+ module Dry
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, Dry::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 [Dry::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
@@ -1,27 +1,29 @@
1
- require 'tilt'
2
- require 'dry-equalizer'
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/cache"
4
+ require "dry/equalizer"
5
+ require_relative "tilt"
3
6
 
4
7
  module Dry
5
- module View
8
+ class View
9
+ # @api private
6
10
  class Renderer
7
- PARTIAL_PREFIX = "_".freeze
8
- PATH_DELIMITER = "/".freeze
11
+ PARTIAL_PREFIX = "_"
12
+ PATH_DELIMITER = "/"
9
13
 
10
- include Dry::Equalizer(:paths, :format, :options)
14
+ extend Dry::Core::Cache
11
15
 
12
- TemplateNotFoundError = Class.new(StandardError)
16
+ include Dry::Equalizer(:paths, :format, :engine_mapping, :options)
13
17
 
14
- attr_reader :paths, :format, :options, :tilts
18
+ TemplateNotFoundError = Class.new(StandardError)
15
19
 
16
- def self.tilts
17
- @__engines__ ||= {}
18
- end
20
+ attr_reader :paths, :format, :engine_mapping, :options
19
21
 
20
- def initialize(paths, format:, **options)
22
+ def initialize(paths, format:, engine_mapping: nil, **options)
21
23
  @paths = paths
22
24
  @format = format
25
+ @engine_mapping = engine_mapping || {}
23
26
  @options = options
24
- @tilts = self.class.tilts
25
27
  end
26
28
 
27
29
  def template(name, scope, &block)
@@ -46,12 +48,13 @@ module Dry
46
48
  def chdir(dirname)
47
49
  new_paths = paths.map { |path| path.chdir(dirname) }
48
50
 
49
- self.class.new(new_paths, format: format)
51
+ self.class.new(new_paths, format: format, **options)
50
52
  end
51
53
 
52
54
  def lookup(name)
53
- paths.inject(false) { |result, path|
54
- result || path.lookup(name, format)
55
+ paths.inject(false) { |_, path|
56
+ result = path.lookup(name, format, include_shared: false)
57
+ break result if result
55
58
  }
56
59
  end
57
60
 
@@ -59,12 +62,12 @@ module Dry
59
62
 
60
63
  def name_for_partial(name)
61
64
  name_segments = name.to_s.split(PATH_DELIMITER)
62
- partial_name = name_segments[0..-2].push("#{PARTIAL_PREFIX}#{name_segments[-1]}").join(PATH_DELIMITER)
65
+ name_segments[0..-2].push("#{PARTIAL_PREFIX}#{name_segments[-1]}").join(PATH_DELIMITER)
63
66
  end
64
67
 
65
68
  def tilt(path)
66
- tilts.fetch(path) {
67
- tilts[path] = Tilt.new(path, nil, **options)
69
+ fetch_or_store(:engine, path, engine_mapping, options) {
70
+ Tilt[path, engine_mapping, **options]
68
71
  }
69
72
  end
70
73
  end
@@ -1,43 +1,175 @@
1
- require 'dry-equalizer'
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/equalizer"
4
+ require "dry/core/constants"
5
+ require_relative "render_environment_missing"
2
6
 
3
7
  module Dry
4
- module View
8
+ class View
9
+ # Evaluation context for templates (including layouts and partials) and
10
+ # provides a place to encapsulate view-specific behaviour alongside a
11
+ # template and its locals.
12
+ #
13
+ # @abstract Subclass this and provide your own methods adding view-specific
14
+ # behavior. You should not override `#initialize`
15
+ #
16
+ # @see https://dry-rb.org/gems/dry-view/templates/
17
+ # @see https://dry-rb.org/gems/dry-view/scopes/
18
+ #
19
+ # @api public
5
20
  class Scope
6
- include Dry::Equalizer(:_locals, :_context, :_renderer)
21
+ # @api private
22
+ CONVENIENCE_METHODS = %i[format context locals].freeze
23
+
24
+ include Dry::Equalizer(:_name, :_locals, :_render_env)
25
+
26
+ # The scope's name
27
+ #
28
+ # @return [Symbol]
29
+ #
30
+ # @api public
31
+ attr_reader :_name
7
32
 
33
+ # The scope's locals
34
+ #
35
+ # @overload _locals
36
+ # Returns the locals
37
+ # @overload locals
38
+ # A convenience alias for `#_format.` Is available unless there is a
39
+ # local named `locals`
40
+ #
41
+ # @return [Hash[<Symbol, Object>]
42
+ #
43
+ # @api public
8
44
  attr_reader :_locals
9
- attr_reader :_context
10
- attr_reader :_renderer
11
45
 
12
- def initialize(renderer:, context: nil, locals: {})
46
+ # The current render environment
47
+ #
48
+ # @return [RenderEnvironment] render environment
49
+ #
50
+ # @api private
51
+ attr_reader :_render_env
52
+
53
+ # Returns a new Scope instance
54
+ #
55
+ # @param name [Symbol, nil] scope name
56
+ # @param locals [Hash<Symbol, Object>] template locals
57
+ # @param render_env [RenderEnvironment] render environment
58
+ #
59
+ # @return [Scope]
60
+ #
61
+ # @api public
62
+ def initialize(name: nil, locals: Dry::Core::Constants::EMPTY_HASH, render_env: RenderEnvironmentMissing.new)
63
+ @_name = name
13
64
  @_locals = locals
14
- @_context = context
15
- @_renderer = renderer
65
+ @_render_env = render_env
66
+ end
67
+
68
+ # @overload render(partial_name, **locals, &block)
69
+ # Renders a partial using the scope
70
+ #
71
+ # @param partial_name [Symbol, String] partial name
72
+ # @param locals [Hash<Symbol, Object>] partial locals
73
+ # @yieldreturn [String] string content to include where the partial calls `yield`
74
+ #
75
+ # @overload render(**locals, &block)
76
+ # Renders a partial (named after the scope's own name) using the scope
77
+ #
78
+ # @param locals[Hash<Symbol, Object>] partial locals
79
+ # @yieldreturn [String] string content to include where the partial calls `yield`
80
+ #
81
+ # @return [String] the rendered partial output
82
+ #
83
+ # @api public
84
+ def render(partial_name = nil, **locals, &block)
85
+ partial_name ||= _name
86
+ raise ArgumentError, "+partial_name+ must be provided for unnamed scopes" unless partial_name
87
+ partial_name = _inflector.underscore(_inflector.demodulize(partial_name.to_s)) if partial_name.is_a?(Class)
88
+
89
+ _render_env.partial(partial_name, _render_scope(locals), &block)
90
+ end
91
+
92
+ # Build a new scope using a scope class matching the provided name
93
+ #
94
+ # @param name [Symbol, Class] scope name (or class)
95
+ # @param locals [Hash<Symbol, Object>] scope locals
96
+ #
97
+ # @return [Scope]
98
+ #
99
+ # @api public
100
+ def scope(name = nil, **locals)
101
+ _render_env.scope(name, locals)
16
102
  end
17
103
 
18
- def render(partial_name, **locals, &block)
19
- _renderer.partial(partial_name, __render_scope(locals), &block)
104
+ # The template format for the current render environment.
105
+ #
106
+ # @overload _format
107
+ # Returns the format.
108
+ # @overload format
109
+ # A convenience alias for `#_format.` Is available unless there is a
110
+ # local named `format`
111
+ #
112
+ # @return [Symbol] format
113
+ #
114
+ # @api public
115
+ def _format
116
+ _render_env.format
117
+ end
118
+
119
+ # The context object for the current render environment
120
+ #
121
+ # @overload _context
122
+ # Returns the context.
123
+ # @overload context
124
+ # A convenience alias for `#_context`. Is available unless there is a
125
+ # local named `context`.
126
+ #
127
+ # @return [Context] context
128
+ #
129
+ # @api public
130
+ def _context
131
+ _render_env.context
20
132
  end
21
133
 
22
134
  private
23
135
 
136
+ # Handles missing methods, according to the following rules:
137
+ #
138
+ # 1. If there is a local with a name matching the method, it returns the
139
+ # local.
140
+ # 2. If the `context` responds to the method, then it will be sent the
141
+ # method and all its arguments.
24
142
  def method_missing(name, *args, &block)
25
143
  if _locals.key?(name)
26
144
  _locals[name]
27
145
  elsif _context.respond_to?(name)
28
146
  _context.public_send(name, *args, &block)
147
+ elsif CONVENIENCE_METHODS.include?(name)
148
+ __send__(:"_#{name}", *args, &block)
29
149
  else
30
150
  super
31
151
  end
32
152
  end
33
153
 
34
- def __render_scope(**locals)
35
- if locals.any?
36
- self.class.new(renderer: _renderer, context: _context, locals: locals)
37
- else
154
+ def respond_to_missing?(name, include_private = false)
155
+ _locals.key?(name) || _render_env.context.respond_to?(name) || CONVENIENCE_METHODS.include?(name) || super
156
+ end
157
+
158
+ def _render_scope(**locals)
159
+ if locals.none?
38
160
  self
161
+ else
162
+ self.class.new(
163
+ # FIXME: what about `name`?
164
+ locals: locals,
165
+ render_env: _render_env,
166
+ )
39
167
  end
40
168
  end
169
+
170
+ def _inflector
171
+ _render_env.inflector
172
+ end
41
173
  end
42
174
  end
43
175
  end