dry-view 0.5.3 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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