hanami-view 2.0.0.alpha7 → 2.1.0.beta1

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +15 -3
  4. data/hanami-view.gemspec +5 -3
  5. data/lib/hanami/view/cache.rb +16 -0
  6. data/lib/hanami/view/context.rb +12 -70
  7. data/lib/hanami/view/context_helpers/content_helpers.rb +5 -5
  8. data/lib/hanami/view/decorated_attributes.rb +2 -2
  9. data/lib/hanami/view/erb/engine.rb +27 -0
  10. data/lib/hanami/view/erb/filters/block.rb +44 -0
  11. data/lib/hanami/view/erb/filters/trimming.rb +42 -0
  12. data/lib/hanami/view/erb/parser.rb +161 -0
  13. data/lib/hanami/view/erb/template.rb +30 -0
  14. data/lib/hanami/view/errors.rb +5 -7
  15. data/lib/hanami/view/exposure.rb +23 -17
  16. data/lib/hanami/view/exposures.rb +22 -13
  17. data/lib/hanami/view/helpers/escape_helper.rb +221 -0
  18. data/lib/hanami/view/helpers/number_formatting_helper.rb +182 -0
  19. data/lib/hanami/view/helpers/tag_helper/tag_builder.rb +230 -0
  20. data/lib/hanami/view/helpers/tag_helper.rb +210 -0
  21. data/lib/hanami/view/html.rb +104 -0
  22. data/lib/hanami/view/html_safe_string_buffer.rb +46 -0
  23. data/lib/hanami/view/part.rb +13 -15
  24. data/lib/hanami/view/part_builder.rb +68 -108
  25. data/lib/hanami/view/path.rb +4 -31
  26. data/lib/hanami/view/renderer.rb +36 -44
  27. data/lib/hanami/view/rendering.rb +42 -0
  28. data/lib/hanami/view/{render_environment_missing.rb → rendering_missing.rb} +8 -13
  29. data/lib/hanami/view/scope.rb +15 -16
  30. data/lib/hanami/view/scope_builder.rb +42 -78
  31. data/lib/hanami/view/tilt/haml_adapter.rb +40 -0
  32. data/lib/hanami/view/tilt/slim_adapter.rb +40 -0
  33. data/lib/hanami/view/tilt.rb +22 -46
  34. data/lib/hanami/view/version.rb +1 -1
  35. data/lib/hanami/view.rb +351 -26
  36. metadata +64 -29
  37. data/LICENSE +0 -20
  38. data/lib/hanami/view/application_configuration.rb +0 -77
  39. data/lib/hanami/view/application_context.rb +0 -98
  40. data/lib/hanami/view/render_environment.rb +0 -62
  41. data/lib/hanami/view/standalone_view.rb +0 -400
  42. data/lib/hanami/view/tilt/erb.rb +0 -26
  43. data/lib/hanami/view/tilt/erbse.rb +0 -21
  44. data/lib/hanami/view/tilt/haml.rb +0 -26
@@ -1,69 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "dry/core/cache"
4
3
  require "tilt"
5
4
 
6
5
  module Hanami
7
6
  class View
8
7
  # @api private
9
8
  module Tilt
10
- extend Dry::Core::Cache
9
+ Mapping = ::Tilt.default_mapping.dup.tap { |mapping|
10
+ # If "slim" has been required before "hanami/view", unregister Slim's non-lazy registered
11
+ # template, so our own template adapter (using register_lazy below) can take precedence.
12
+ mapping.unregister "slim"
13
+
14
+ # Register our own ERB template.
15
+ mapping.register_lazy "Hanami::View::ERB::Template", "hanami/view/erb/template", "erb", "rhtml"
16
+
17
+ # Register ERB templates for Haml and Slim that set the `use_html_safe: true` option.
18
+ #
19
+ # Our template namespaces below have the "Adapter" suffix to work around a bug in Tilt's
20
+ # `Mapping#const_defined?`, which (if slim was already required) would receive
21
+ # "Hanami::View::Slim::Template" and return `Slim::Template`, which is the opposite of what
22
+ # we want.
23
+ mapping.register_lazy "Hanami::View::Tilt::HamlAdapter::Template", "hanami/view/tilt/haml_adapter", "haml"
24
+ mapping.register_lazy "Hanami::View::Tilt::SlimAdapter::Template", "hanami/view/tilt/slim_adapter", "slim"
25
+ }
11
26
 
12
27
  class << self
13
- def [](path, mapping, **options)
14
- ext = File.extname(path).sub(/^./, "").to_sym
15
- activate_adapter ext
16
-
17
- with_mapping(mapping).new(path, **options)
18
- end
19
-
20
- def default_mapping
21
- ::Tilt.default_mapping
22
- end
23
-
24
- def register_adapter(ext, adapter)
25
- adapters[ext] = adapter
26
- end
27
-
28
- def deregister_adapter(ext)
29
- adapters.delete(ext)
28
+ def [](path, mapping, options)
29
+ with_mapping(mapping).new(path, options)
30
30
  end
31
31
 
32
32
  private
33
33
 
34
- def adapters
35
- @adapters ||= {}
36
- end
37
-
38
- def activate_adapter(ext)
39
- fetch_or_store(:adapter, ext) {
40
- adapter = adapters[ext]
41
- return unless adapter
42
-
43
- *requires, error_message = adapter.requirements
44
-
45
- begin
46
- requires.each(&method(:require))
47
- rescue LoadError => e
48
- raise e, "#{e.message}\n\n#{error_message}"
49
- end
50
-
51
- adapter.activate
52
- }
53
- end
54
-
55
34
  def with_mapping(mapping)
56
- fetch_or_store(:mapping, mapping) {
35
+ View.cache.fetch_or_store(:tilt_mapping, mapping) {
57
36
  if mapping.any?
58
37
  build_mapping(mapping)
59
38
  else
60
- default_mapping
39
+ Mapping
61
40
  end
62
41
  }
63
42
  end
64
43
 
65
44
  def build_mapping(mapping)
66
- default_mapping.dup.tap do |new_mapping|
45
+ Mapping.dup.tap do |new_mapping|
67
46
  mapping.each do |extension, template_class|
68
47
  new_mapping.register template_class, extension
69
48
  end
@@ -73,6 +52,3 @@ module Hanami
73
52
  end
74
53
  end
75
54
  end
76
-
77
- require_relative "tilt/erb"
78
- require_relative "tilt/haml"
@@ -3,6 +3,6 @@
3
3
  module Hanami
4
4
  class View
5
5
  # @api private
6
- VERSION = "2.0.0.alpha7"
6
+ VERSION = "2.1.0.beta1"
7
7
  end
8
8
  end
data/lib/hanami/view.rb CHANGED
@@ -1,21 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "dry/configurable"
4
- require "dry/core/cache"
5
4
  require "dry/core/equalizer"
6
5
  require "dry/inflector"
6
+ require "zeitwerk"
7
7
 
8
- require_relative "view/application_view"
9
- require_relative "view/context"
10
- require_relative "view/exposures"
11
8
  require_relative "view/errors"
12
- require_relative "view/part_builder"
13
- require_relative "view/path"
14
- require_relative "view/render_environment"
15
- require_relative "view/rendered"
16
- require_relative "view/renderer"
17
- require_relative "view/scope_builder"
18
- require_relative "view/standalone_view"
9
+ require_relative "view/html"
19
10
 
20
11
  module Hanami
21
12
  # A standalone, template-based view rendering system that offers everything
@@ -32,13 +23,34 @@ module Hanami
32
23
  #
33
24
  # @api public
34
25
  class View
26
+ # @since 2.1.0
27
+ # @api private
28
+ def self.gem_loader
29
+ @gem_loader ||= Zeitwerk::Loader.new.tap do |loader|
30
+ root = File.expand_path("..", __dir__)
31
+ loader.tag = "hanami-view"
32
+ loader.push_dir(root)
33
+ loader.ignore(
34
+ "#{root}/hanami-view.rb",
35
+ "#{root}/hanami/view/version.rb",
36
+ "#{root}/hanami/view/errors.rb",
37
+ )
38
+ loader.inflector = Zeitwerk::GemInflector.new("#{root}/hanami-view.rb")
39
+ loader.inflector.inflect(
40
+ "erb" => "ERB",
41
+ "html" => "HTML",
42
+ "html_safe_string_buffer" => "HTMLSafeStringBuffer",
43
+ )
44
+ end
45
+ end
46
+
47
+ gem_loader.setup
48
+
35
49
  # @api private
36
50
  DEFAULT_RENDERER_OPTIONS = {default_encoding: "utf-8"}.freeze
37
51
 
38
52
  include Dry::Equalizer(:config, :exposures)
39
53
 
40
- extend Dry::Core::Cache
41
-
42
54
  extend Dry::Configurable
43
55
 
44
56
  # @!group Configuration
@@ -143,6 +155,8 @@ module Hanami
143
155
  # @!scope class
144
156
  setting :default_format, default: :html
145
157
 
158
+ setting :part_class, default: Part
159
+
146
160
  # @overload config.scope_namespace=(namespace)
147
161
  # Set a namespace that will be searched when building scope classes.
148
162
  #
@@ -164,6 +178,8 @@ module Hanami
164
178
  # @!scope class
165
179
  setting :part_builder, default: PartBuilder
166
180
 
181
+ setting :scope_class, default: Scope
182
+
167
183
  # @overload config.scope_namespace=(namespace)
168
184
  # Set a namespace that will be searched when building scope classes.
169
185
  #
@@ -226,28 +242,337 @@ module Hanami
226
242
  # @param mapping [Hash<Symbol, Class>] engine mapping
227
243
  # @api public
228
244
  # @!scope class
229
- setting :renderer_engine_mapping
245
+ setting :renderer_engine_mapping, default: {}
230
246
 
231
247
  # @!endgroup
232
248
 
233
- include StandaloneView
234
-
235
- def self.inherited(subclass)
249
+ # @api private
250
+ def self.inherited(klass)
236
251
  super
237
252
 
238
- # When inheriting within an Hanami app, and the application provider has
239
- # changed from the superclass, (re-)configure the action for the provider,
240
- # i.e. for the slice and/or the application itself
241
- if (provider = application_provider(subclass)) && provider != application_provider(subclass.superclass)
242
- subclass.include ApplicationView.new(provider)
253
+ exposures.each do |name, exposure|
254
+ klass.exposures.import(name, exposure)
255
+ end
256
+ end
257
+
258
+ # @!group Exposures
259
+
260
+ # @!macro [new] exposure_options
261
+ # @param options [Hash] the exposure's options
262
+ # @option options [Boolean] :layout expose this value to the layout (defaults to false)
263
+ # @option options [Boolean] :decorate decorate this value in a matching Part (defaults to
264
+ # true)
265
+ # @option options [Symbol, Class] :as an alternative name or class to use when finding a
266
+ # matching Part
267
+
268
+ # @overload expose(name, **options, &block)
269
+ # Define a value to be passed to the template. The return value of the
270
+ # block will be decorated by a matching Part and passed to the template.
271
+ #
272
+ # The block will be evaluated with the view instance as its `self`. The
273
+ # block's parameters will determine what it is given:
274
+ #
275
+ # - To receive other exposure values, provide positional parameters
276
+ # matching the exposure names. These exposures will already by decorated
277
+ # by their Parts.
278
+ # - To receive the view's input arguments (whatever is passed to
279
+ # `View#call`), provide matching keyword parameters. You can provide
280
+ # default values for these parameters to make the corresponding input
281
+ # keys optional
282
+ # - To receive the Context object, provide a `context:` keyword parameter
283
+ # - To receive the view's input arguments in their entirety, provide a
284
+ # keywords splat parameter (i.e. `**input`)
285
+ #
286
+ # @example Accessing input arguments
287
+ # expose :article do |slug:|
288
+ # article_repo.find_by_slug(slug)
289
+ # end
290
+ #
291
+ # @example Accessing other exposures
292
+ # expose :articles do
293
+ # article_repo.listing
294
+ # end
295
+ #
296
+ # expose :featured_articles do |articles|
297
+ # articles.select(&:featured?)
298
+ # end
299
+ #
300
+ # @param name [Symbol] name for the exposure
301
+ # @macro exposure_options
302
+ #
303
+ # @overload expose(name, **options)
304
+ # Define a value to be passed to the template, provided by an instance
305
+ # method matching the name. The method's return value will be decorated by
306
+ # a matching Part and passed to the template.
307
+ #
308
+ # The method's parameters will determine what it is given:
309
+ #
310
+ # - To receive other exposure values, provide positional parameters
311
+ # matching the exposure names. These exposures will already by decorated
312
+ # by their Parts.
313
+ # - To receive the view's input arguments (whatever is passed to
314
+ # `View#call`), provide matching keyword parameters. You can provide
315
+ # default values for these parameters to make the corresponding input
316
+ # keys optional
317
+ # - To receive the Context object, provide a `context:` keyword parameter
318
+ # - To receive the view's input arguments in their entirey, provide a
319
+ # keywords splat parameter (i.e. `**input`)
320
+ #
321
+ # @example Accessing input arguments
322
+ # expose :article
323
+ #
324
+ # def article(slug:)
325
+ # article_repo.find_by_slug(slug)
326
+ # end
327
+ #
328
+ # @example Accessing other exposures
329
+ # expose :articles
330
+ # expose :featured_articles
331
+ #
332
+ # def articles
333
+ # article_repo.listing
334
+ # end
335
+ #
336
+ # def featured_articles
337
+ # articles.select(&:featured?)
338
+ # end
339
+ #
340
+ # @param name [Symbol] name for the exposure
341
+ # @macro exposure_options
342
+ #
343
+ # @overload expose(name, **options)
344
+ # Define a single value to pass through from the input data (when there is
345
+ # no instance method matching the `name`). This value will be decorated by
346
+ # a matching Part and passed to the template.
347
+ #
348
+ # @param name [Symbol] name for the exposure
349
+ # @macro exposure_options
350
+ # @option options [Boolean] :default a default value to provide if there is no matching
351
+ # input data
352
+ #
353
+ # @overload expose(*names, **options)
354
+ # Define multiple values to pass through from the input data (when there
355
+ # is no instance methods matching their names). These values will be
356
+ # decorated by matching Parts and passed through to the template.
357
+ #
358
+ # The provided options will be applied to all the exposures.
359
+ #
360
+ # @param names [Symbol] names for the exposures
361
+ # @macro exposure_options
362
+ # @option options [Boolean] :default a default value to provide if there is no matching
363
+ # input data
364
+ #
365
+ # @see https://dry-rb.org/gems/dry-view/exposures/
366
+ #
367
+ # @api public
368
+ def self.expose(*names, **options, &block)
369
+ if names.length == 1
370
+ exposures.add(names.first, block, **options)
371
+ else
372
+ names.each do |name|
373
+ exposures.add(name, **options)
374
+ end
375
+ end
376
+ end
377
+
378
+ # @api public
379
+ def self.private_expose(*names, **options, &block)
380
+ expose(*names, **options, private: true, &block)
381
+ end
382
+
383
+ # Returns the defined exposures. These are unbound, since bound exposures
384
+ # are only created when initializing a View instance.
385
+ #
386
+ # @return [Exposures]
387
+ # @api private
388
+ def self.exposures
389
+ @exposures ||= Exposures.new
390
+ end
391
+
392
+ # @!endgroup
393
+
394
+ # @!group Scope
395
+
396
+ # Creates and assigns a scope for the current view.
397
+ #
398
+ # The newly created scope is useful to add custom logic that is specific
399
+ # to the view.
400
+ #
401
+ # The scope has access to locals, exposures, and inherited scope (if any)
402
+ #
403
+ # If the view already has an explicit scope the newly created scope will
404
+ # inherit from the explicit scope.
405
+ #
406
+ # There are two cases when this may happen:
407
+ # 1. The scope was explicitly assigned (e.g. `config.scope = MyScope`)
408
+ # 2. The scope has been inherited by the view superclass
409
+ #
410
+ # If the view doesn't have an already existing scope, the newly scope
411
+ # will inherit from `Hanami::View::Scope` by default.
412
+ #
413
+ # However, you can specify any base class for it. This is not
414
+ # recommended, unless you know what you're doing.
415
+ #
416
+ # @param scope [Hanami::View::Scope] the current scope (if any), or the
417
+ # default base class will be `Hanami::View::Scope`
418
+ # @param block [Proc] the scope logic definition
419
+ #
420
+ # @api public
421
+ #
422
+ # @example Basic usage
423
+ # class MyView < Hanami::View
424
+ # config.scope = MyScope
425
+ #
426
+ # scope do
427
+ # def greeting
428
+ # _locals[:message].upcase + "!"
429
+ # end
430
+ #
431
+ # def copyright(time)
432
+ # "Copy #{time.year}"
433
+ # end
434
+ # end
435
+ # end
436
+ #
437
+ # # my_view.html.erb
438
+ # # <%= greeting %>
439
+ # # <%= copyright(Time.now.utc) %>
440
+ #
441
+ # MyView.new.(message: "Hello") # => "HELLO!"
442
+ #
443
+ # @example Inherited scope
444
+ # class MyScope < Hanami::View::Scope
445
+ # private
446
+ #
447
+ # def shout(string)
448
+ # string.upcase + "!"
449
+ # end
450
+ # end
451
+ #
452
+ # class MyView < Hanami::View
453
+ # config.scope = MyScope
454
+ #
455
+ # scope do
456
+ # def greeting
457
+ # shout(_locals[:message])
458
+ # end
459
+ #
460
+ # def copyright(time)
461
+ # "Copy #{time.year}"
462
+ # end
463
+ # end
464
+ # end
465
+ #
466
+ # # my_view.html.erb
467
+ # # <%= greeting %>
468
+ # # <%= copyright(Time.now.utc) %>
469
+ #
470
+ # MyView.new.(message: "Hello") # => "HELLO!"
471
+ def self.scope(scope_class = nil, &block)
472
+ scope_class ||= config.scope || config.scope_class
473
+
474
+ config.scope = Class.new(scope_class, &block)
475
+ end
476
+
477
+ # @!endgroup
478
+
479
+ # @api private
480
+ def self.layout_path
481
+ File.join(*[config.layouts_dir, config.layout].compact)
482
+ end
483
+
484
+ # @api private
485
+ def self.cache
486
+ Cache
487
+ end
488
+
489
+ # Returns an instance of the view. This binds the defined exposures to the
490
+ # view instance.
491
+ #
492
+ # Subclasses can define their own `#initialize` to accept injected
493
+ # dependencies, but must call `super()` to ensure the standard view
494
+ # initialization can proceed.
495
+ #
496
+ # @api public
497
+ def initialize
498
+ self.class.config.finalize!
499
+ ensure_config
500
+
501
+ @exposures = self.class.exposures.bind(self)
502
+ end
503
+
504
+ # The view's configuration
505
+ #
506
+ # @api private
507
+ def config
508
+ self.class.config
509
+ end
510
+
511
+ # The view's bound exposures
512
+ #
513
+ # @return [Exposures]
514
+ # @api private
515
+ def exposures
516
+ @exposures
517
+ end
518
+
519
+ # Render the view
520
+ #
521
+ # @param format [Symbol] template format to use
522
+ # @param context [Context] context object to use
523
+ # @param input input data for preparing exposure values
524
+ #
525
+ # @return [Rendered] rendered view object
526
+ # @api public
527
+ def call(format: config.default_format, context: config.default_context, **input)
528
+ rendering = self.rendering(format: format, context: context)
529
+
530
+ locals = locals(rendering, input)
531
+ output = rendering.template(config.template, rendering.scope(config.scope, locals))
532
+
533
+ if layout?
534
+ begin
535
+ output = rendering.template(
536
+ self.class.layout_path,
537
+ rendering.scope(config.scope, layout_locals(locals))
538
+ ) { output }
539
+ rescue TemplateNotFoundError
540
+ raise LayoutNotFoundError.new(config.layout, config.paths)
541
+ end
243
542
  end
543
+
544
+ Rendered.new(output: output, locals: locals)
244
545
  end
245
546
 
246
- def self.application_provider(subclass)
247
- if Hanami.respond_to?(:application?) && Hanami.application?
248
- Hanami.application.component_provider(subclass)
547
+ def rendering(format: config.default_format, context: config.default_context)
548
+ Rendering.new(config: config, format: format, context: context)
549
+ end
550
+
551
+ private
552
+
553
+ def ensure_config
554
+ raise UndefinedConfigError, :paths unless Array(config.paths).any?
555
+ raise UndefinedConfigError, :template unless config.template
556
+ end
557
+
558
+ def locals(rendering, input)
559
+ exposures.(context: rendering.context, **input) do |value, exposure|
560
+ if exposure.decorate? && value
561
+ rendering.part(exposure.name, value, as: exposure.options[:as])
562
+ else
563
+ value
564
+ end
249
565
  end
250
566
  end
251
- private_class_method :application_provider
567
+
568
+ def layout_locals(locals)
569
+ locals.each_with_object({}) do |(key, value), layout_locals|
570
+ layout_locals[key] = value if exposures[key].for_layout?
571
+ end
572
+ end
573
+
574
+ def layout?
575
+ !!config.layout # rubocop:disable Style/DoubleNegation
576
+ end
252
577
  end
253
578
  end