phlex-hanami 0.2.1 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 301d34d14bd2989ab0ab899fd6f76e38b5a8e8b1a3fcffa182f6ece292652e81
4
- data.tar.gz: 74e00aebe59f62800ad37bd576ab97f71efbfbabb536d5fa115e8b2ce5789509
3
+ metadata.gz: af266b5dd1d9a3b34563f84a472fe71d4e49cd1d2ea4e64982c61631bed12730
4
+ data.tar.gz: 88d3d2d0fa2aae7942f8d1d6c11386dcc06d7cae3ad7a8cff0de4647e0addbe2
5
5
  SHA512:
6
- metadata.gz: 84d0aec75d54b2e1e7be95d57c9d2c7e9afb560abd4ec7b86065136618775988a33776da0195c71222f9921c628c7b3b6168ca8cc84710f8a18709114210d6a6
7
- data.tar.gz: 8ab7a57fac797d8daa565d6335ed9efbe2b1cfde7c0f67f30ab98a5c8c01341ee6a2b46ebb0eced44807084080fc6f5d73bec15914f16780163143f4b1b13cc3
6
+ metadata.gz: 74cef7af6b2d42741c0d900c1772c3cf8a58a07ed3a5eac60fb4e8a1da72b57b0ba3a613f3be2bba92db3aa35fd3c93b914b222b724384edfc7c1843d42c2d90
7
+ data.tar.gz: 0e9b852908aa28e2f236637093642ad2d4b8f22c4e6a9711fea40d7f7789e406a3aacb85c9fc0109d2d19a7a217e3951a34f6e8823c6db5172d9e14ebe7c4931
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [v0.3.0] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - A view's `routes`, `path` and `url` now use the app's routes, even in a slice with a routes file of its own. That
15
+ slice's own routes knew only its own names and dropped the prefix it is mounted at, so `path(:posts)` in a slice
16
+ mounted at `/blog` gave `"/posts"`. A view now names the route the way an action does: `path(:blog_posts)` gives
17
+ `"/blog/posts"`. Slices mounted at `"/"` keep their names. With hanami-view bundled, Hanami builds the context, so
18
+ the fix comes with Hanami's next release. ([#7](https://github.com/aaronmallen/phlex-hanami/issues/7))
19
+
20
+ ### Fixed
21
+
22
+ - Mail views and layouts follow `config.actions.view_name_inference_base`. A slice that sets it to `"ui.views"` now
23
+ finds `ui.views.mailers.welcome`, `ui.views.layout` and `ui.views.mailers.layout`, where before it looked only
24
+ under `views`. ([#6](https://github.com/aaronmallen/phlex-hanami/issues/6))
25
+
26
+ ## [v0.2.2] - 2026-09-26
27
+
28
+ ### Added
29
+
30
+ - A kind for `prop` in `Phlex::Hanami::Props`, as its third argument: `:positional`, `:*` for the leftover
31
+ positional arguments as an Array, or `:**` for the leftover keywords as a Hash. A component can now take
32
+ `prop :attributes, Types::Hash, :**` and pass HTML attributes through to its tag. A view with a `:**` prop receives
33
+ every exposure and request param.
34
+ - `prop?`, for a prop the caller can leave out. When they do, it holds `Phlex::Hanami::Props::UNSET` rather than
35
+ `nil`, so a form can use the session's CSRF token by default and none when passed `token: nil`.
36
+ - A block on `prop`, which runs on the new instance and coerces the value before the type sees it.
37
+ - `reader:`, `writer:` and `predicate:` options on `prop`, each taking `:public`, `:protected` or `:private`. The
38
+ writer checks the value against the type.
39
+ - `after_initialize`, which a class can define to run once every prop is set.
40
+
41
+ ### Changed
42
+
43
+ - A prop whose type accepts `nil`, such as `Types::String.optional` or `NilClass`, is now optional and `nil` when
44
+ left out. It used to be required.
45
+ - A default proc now runs on the new instance rather than on its own, so it can read the props declared above it. A
46
+ proc that relied on `self` being the class it was written in needs to change.
47
+
10
48
  ## [v0.2.1] - 2026-09-26
11
49
 
12
50
  ### Added
@@ -108,7 +146,10 @@ Initial alpha release by the new maintainer [@aaronmallen](https://github.com/aa
108
146
 
109
147
  Initial release, by the previous maintainer [@stephannv](https://github.com/stephannv).
110
148
 
111
- [Unreleased]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0...HEAD
149
+ [Unreleased]: https://github.com/aaronmallen/phlex-hanami/compare/0.3.0...HEAD
150
+ [v0.3.0]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.2...0.3.0
151
+ [v0.2.2]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.1...0.2.2
152
+ [v0.2.1]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0...0.2.1
112
153
  [v0.2.0]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0-alpha.3...0.2.0
113
154
  [v0.2.0-alpha.3]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0-alpha.2...0.2.0-alpha.3
114
155
  [v0.2.0-alpha.2]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0-alpha.1...0.2.0-alpha.2
@@ -7,10 +7,10 @@ module Phlex
7
7
  # The type's own error, when there is one, is kept as the `cause`.
8
8
  #
9
9
  # @api public
10
- # @since 0.3.0
10
+ # @since 0.2.1
11
11
  class InvalidPropError < Error
12
12
  # @api private
13
- # @since 0.3.0
13
+ # @since 0.2.1
14
14
  #: (Module, Symbol, String) -> void
15
15
  def initialize(view_class, name, reason)
16
16
  super(<<~MESSAGE)
@@ -42,8 +42,10 @@ module Phlex
42
42
  #
43
43
  # The key is derived the way hanami-mailer derives a template name: underscore the class
44
44
  # name and drop the slice's own segment, so `MyApp::Mailers::Welcome` looks up
45
- # `views.mailers.welcome`. Resolved through the container on every call rather than
46
- # memoized, so code reloading is not defeated.
45
+ # `views.mailers.welcome`. The `views` is the slice's
46
+ # `config.actions.view_name_inference_base`, so mail views live where action views do.
47
+ # Resolved through the container on every call rather than memoized, so code reloading is
48
+ # not defeated.
47
49
  #
48
50
  # @return [Class, nil]
49
51
  #
@@ -56,7 +58,7 @@ module Phlex
56
58
  mailer_slice = slice
57
59
  return nil unless mailer_slice && name
58
60
 
59
- key = "views.#{view_name(mailer_slice)}"
61
+ key = Extensions::Slice.view_key(mailer_slice, view_name(mailer_slice))
60
62
  return nil unless mailer_slice.key?(key)
61
63
 
62
64
  view = mailer_slice[key]
@@ -109,6 +109,26 @@ module Phlex
109
109
  define_view_context(slice)
110
110
  end
111
111
 
112
+ # The container key for a view, under the base Hanami pairs an action's view with.
113
+ #
114
+ # Reads `config.actions.view_name_inference_base`, so a slice that keeps its views under
115
+ # `ui.views` finds its layouts and mail views there too. Falls back to `views` when
116
+ # hanami-action is not bundled, because Hanami has no such setting then.
117
+ #
118
+ # @example
119
+ # Slice.view_key(MyApp::App, "mailers.welcome") # => "views.mailers.welcome"
120
+ #
121
+ # @return [String]
122
+ #
123
+ # @api private
124
+ # @since 0.3.0
125
+ #: (singleton(::Hanami::Slice), String) -> String
126
+ def view_key(slice, name)
127
+ actions = slice.config.actions
128
+ base = actions.respond_to?(:view_name_inference_base) ? actions.view_name_inference_base : "views"
129
+ [base, name].compact.join(".")
130
+ end
131
+
112
132
  private
113
133
 
114
134
  #: (singleton(::Hanami::Slice)) -> Module
@@ -59,7 +59,8 @@ module Phlex
59
59
 
60
60
  private
61
61
 
62
- # A slice's conventional mail layout: `Views::Mailers::Layout`, if it defines one.
62
+ # A slice's conventional mail layout: `views.mailers.layout` in its container, if it has
63
+ # one, under whatever base the slice pairs action views with.
63
64
  #
64
65
  # The web layout is the wrong one for an email — it carries the stylesheets, scripts and
65
66
  # page chrome no mail client wants — so mail looks for its own, and renders without a
@@ -67,13 +68,9 @@ module Phlex
67
68
  #: () -> singleton(::Phlex::SGML)?
68
69
  def default_layout
69
70
  return nil unless slice
70
- return nil unless slice.namespace.const_defined?(:Views, false)
71
71
 
72
- views = slice.namespace.const_get(:Views, false)
73
- return nil unless views.const_defined?(:Mailers, false)
74
-
75
- mailers = views.const_get(:Mailers, false)
76
- mailers.const_get(:Layout, false) if mailers.const_defined?(:Layout, false)
72
+ key = Extensions::Slice.view_key(slice, "mailers.layout")
73
+ slice[key] if slice.key?(key)
77
74
  end
78
75
 
79
76
  # The slice's view context, built without a request.
@@ -4,14 +4,15 @@ module Phlex
4
4
  module Hanami
5
5
  # Declared props, typed with dry-types.
6
6
  #
7
- # Opt in per class. Each `prop` names a keyword `initialize` takes and the type its value goes
7
+ # Opt in per class. Each `prop` names an argument `initialize` takes and the type its value goes
8
8
  # through, and the value lands in an instance variable of the same name. A dry type is called,
9
9
  # so it coerces as well as checks: `Types::Params::Integer` turns the string a request param
10
10
  # arrives as into an Integer. Anything else that answers `call` is called the same way, and
11
11
  # anything that does not, such as a plain class, is matched with `===`.
12
12
  #
13
13
  # The initializer it builds takes real keywords, so auto render hands a view the props it
14
- # declares and drops every other param, the same as it does for a hand written `initialize`.
14
+ # declares and drops every other param, the same as it does for a hand written `initialize`. A
15
+ # `:**` prop takes them all, request params included.
15
16
  #
16
17
  # Literal needs none of this. Extend `Literal::Properties` instead if you would rather use it.
17
18
  #
@@ -23,85 +24,153 @@ module Phlex
23
24
  # prop :count, Types::Params::Integer
24
25
  # prop :compact, Types::Bool, default: false
25
26
  # prop :tags, Types::Array.of(Types::String), default: -> { [] }
27
+ # prop :attributes, Types::Hash, :**
26
28
  #
27
29
  # def view_template
28
- # article(class: ("compact" if @compact)) { h2 { @post.title } }
30
+ # article(class: ("compact" if @compact), **@attributes) { h2 { @post.title } }
29
31
  # end
30
32
  # end
31
33
  #
32
34
  # @api public
33
- # @since 0.3.0
35
+ # @since 0.2.1
34
36
  module Props
35
- # Stands in for a keyword the caller left out, since `nil` can be a real value.
37
+ # The value of a prop declared with `prop?` that the caller left out, so a view can tell it
38
+ # from `nil`.
36
39
  #
37
- # @api private
38
- # @since 0.3.0
39
- UNSET = ::Object.new.freeze #: Object
40
+ # @example
41
+ # prop? :token, Types::String.optional
42
+ #
43
+ # def token_given? = !Phlex::Hanami::Props::UNSET.equal?(@token)
44
+ #
45
+ # @api public
46
+ # @since 0.2.1
47
+ UNSET = ::Object.new.tap { |unset| def unset.inspect = "Phlex::Hanami::Props::UNSET" }.freeze #: Object
40
48
 
41
49
  # How the generated initializer names {UNSET}.
42
50
  #
43
51
  # @api private
44
- # @since 0.3.0
52
+ # @since 0.2.1
45
53
  UNSET_PATH = "::Phlex::Hanami::Props::UNSET" #: String
46
54
 
47
- # A prop name has to be a Ruby identifier, because it becomes a keyword and an instance
55
+ # A prop name has to be a Ruby identifier, because it becomes an argument and an instance
48
56
  # variable.
49
57
  #
50
58
  # @api private
51
- # @since 0.3.0
59
+ # @since 0.2.1
52
60
  NAME_FORMAT = /\A[a-z_][a-zA-Z0-9_]*\z/ #: Regexp
53
61
 
54
- # Sets each prop's instance variable from the keywords the generated initializer received.
62
+ # The argument kinds a prop can take, in the order `initialize` declares them.
55
63
  #
56
64
  # @api private
57
65
  # @since 0.3.0
66
+ KINDS = %i[positional * keyword **].freeze #: Array[Symbol]
67
+
68
+ # What `reader`, `writer` and `predicate` accept.
69
+ #
70
+ # @api private
71
+ # @since 0.3.0
72
+ VISIBILITIES = [false, :public, :protected, :private].freeze #: Array[Symbol | false]
73
+
74
+ # Sets each prop's instance variable from the arguments the generated initializer received.
75
+ #
76
+ # @api private
77
+ # @since 0.2.1
58
78
  #: (untyped, Binding) -> void
59
79
  def self.assign(view, arguments)
60
80
  view.class.props.each_value do |prop|
61
- value = prop.resolve(view.class, arguments.local_variable_get(prop.name))
81
+ value = prop.resolve(view, arguments.local_variable_get(prop.variable))
62
82
  view.instance_variable_set(:"@#{prop.name}", value)
63
83
  end
64
84
  end
65
85
 
66
86
  # @api private
67
- # @since 0.3.0
87
+ # @since 0.2.1
68
88
  #: (Module) -> void
69
89
  def self.included(view_class)
70
90
  view_class.extend(ClassMethods)
71
91
  end
72
92
 
73
93
  # @api public
74
- # @since 0.3.0
94
+ # @since 0.2.1
75
95
  module ClassMethods
76
96
  # Declares a prop.
77
97
  #
78
- # A prop with a default is optional. So is one whose dry type carries its own, as
79
- # `Types::Bool.default(false)` does. A default goes through the type like any other value.
80
- # Pass a proc for anything mutable, so each instance gets its own.
98
+ # A prop with a default is optional. So is one whose type accepts nil, which it gets when
99
+ # left out, and one whose dry type carries its own default, as `Types::Bool.default(false)`
100
+ # does. A default goes through the type like any other value. Pass a proc for anything
101
+ # mutable, so each instance gets its own. The proc runs on the new instance, so it can read
102
+ # the props declared above it.
103
+ #
104
+ # A block, when given, runs on the new instance with the value before the type sees it.
105
+ #
106
+ # @param name [Symbol] the argument, and the instance variable the value lands in
107
+ # @param type [#call, #===] a dry type, or anything that answers `call` or `===`
108
+ # @param kind [Symbol] `:keyword`, `:positional`, `:*` for the rest of the positional
109
+ # arguments as an Array, or `:**` for the rest of the keywords as a Hash
110
+ # @param default [Object, Proc] the value when the argument is left out
111
+ # @param reader [false, Symbol] the visibility of a reader method, or false for none
112
+ # @param writer [false, Symbol] the visibility of a writer method that checks the type
113
+ # @param predicate [false, Symbol] the visibility of a `name?` method
114
+ #
115
+ # @return [Symbol] the name
116
+ #
117
+ # @raise [ArgumentError] if an option is not valid, or the argument would not fit the
118
+ # ones declared before it
119
+ #
120
+ # @api public
121
+ # @since 0.2.1
122
+ # @rbs name: Symbol
123
+ # @rbs type: untyped
124
+ # @rbs kind: Symbol
125
+ # @rbs default: untyped
126
+ # @rbs reader: Symbol | false
127
+ # @rbs writer: Symbol | false
128
+ # @rbs predicate: Symbol | false
129
+ # @rbs &coercion: ? (untyped) -> untyped
130
+ # @rbs return: Symbol
131
+ def prop(name, type, kind = :keyword, default: UNSET, reader: false, writer: false, predicate: false, &coercion)
132
+ prop = Prop.new(name, type, kind, default:, omittable: false, coercion:)
133
+ declare_prop(prop, { reader:, writer:, predicate: })
134
+ end
135
+
136
+ # Declares a prop the caller can leave out, and that is set to {UNSET} when they do. A view
137
+ # can then tell an argument left out from one passed as `nil`.
138
+ #
139
+ # The type does not see {UNSET}, so it only has to accept what a caller passes.
81
140
  #
82
- # @param name [Symbol] the keyword, and the instance variable the value lands in
141
+ # @param name [Symbol] the argument, and the instance variable the value lands in
83
142
  # @param type [#call, #===] a dry type, or anything that answers `call` or `===`
84
- # @param default [Object, Proc] the value when the keyword is left out
143
+ # @param kind [Symbol] `:keyword` or `:positional`
144
+ # @param reader [false, Symbol] the visibility of a reader method, or false for none
145
+ # @param writer [false, Symbol] the visibility of a writer method that checks the type
146
+ # @param predicate [false, Symbol] the visibility of a `name?` method
85
147
  #
86
148
  # @return [Symbol] the name
87
149
  #
88
- # @raise [ArgumentError] if the name is not a Ruby identifier
150
+ # @raise [ArgumentError] if an option is not valid, or the argument would not fit the
151
+ # ones declared before it
89
152
  #
90
153
  # @api public
91
154
  # @since 0.3.0
92
- #: (Symbol, untyped, ?default: untyped) -> Symbol
93
- def prop(name, type, default: UNSET)
94
- raise ArgumentError, "#{name.inspect} is not a valid prop name" unless NAME_FORMAT.match?(name.to_s)
155
+ # @rbs name: Symbol
156
+ # @rbs type: untyped
157
+ # @rbs kind: Symbol
158
+ # @rbs reader: Symbol | false
159
+ # @rbs writer: Symbol | false
160
+ # @rbs predicate: Symbol | false
161
+ # @rbs &coercion: ? (untyped) -> untyped
162
+ # @rbs return: Symbol
163
+ def prop?(name, type, kind = :keyword, reader: false, writer: false, predicate: false, &coercion)
164
+ raise ArgumentError, "prop? takes a :keyword or :positional kind" unless %i[keyword positional].include?(kind)
95
165
 
96
- own_props[name] = Prop.new(name, type, default)
97
- define_props_initializer
98
- name
166
+ prop = Prop.new(name, type, kind, default: UNSET, omittable: true, coercion:)
167
+ declare_prop(prop, { reader:, writer:, predicate: })
99
168
  end
100
169
 
101
170
  # Every prop this class declares, its superclasses' first.
102
171
  #
103
172
  # @api public
104
- # @since 0.3.0
173
+ # @since 0.2.1
105
174
  #: () -> Hash[Symbol, Prop]
106
175
  def props
107
176
  inherited = superclass.respond_to?(:props) ? superclass.props : {} #: Hash[Symbol, Prop]
@@ -110,20 +179,90 @@ module Phlex
110
179
 
111
180
  private
112
181
 
182
+ #: (Prop, Hash[Symbol, Symbol | false]) -> void
183
+ def check_methods(prop, methods)
184
+ methods.each do |option, visibility|
185
+ next if VISIBILITIES.include?(visibility)
186
+
187
+ raise ArgumentError, "#{option} must be one of #{VISIBILITIES.map(&:inspect).join(', ')}"
188
+ end
189
+
190
+ # A reader would replace `Object#class`, which Phlex and Hanami both call.
191
+ raise ArgumentError, "the :class prop cannot have a reader" if methods[:reader] && prop.name == :class
192
+ end
193
+
194
+ # Ruby allows no required positional argument after an optional one once there is a `*`,
195
+ # and the order would surprise a caller anyway.
196
+ #: (Prop, Array[Prop]) -> void
197
+ def check_positional_order(prop, others)
198
+ return unless prop.kind == :positional && !prop.optional?
199
+ return unless others.any? { |other| other.kind == :positional && other.optional? }
200
+
201
+ raise ArgumentError, "the required positional prop #{prop.name.inspect} follows an optional one"
202
+ end
203
+
204
+ #: (Prop, Array[Prop]) -> void
205
+ def check_splat(prop, others)
206
+ return unless %i[* **].include?(prop.kind) && others.any? { |other| other.kind == prop.kind }
207
+
208
+ raise ArgumentError, "#{self} already has a #{prop.kind.inspect} prop"
209
+ end
210
+
211
+ #: (Prop, Hash[Symbol, Symbol | false]) -> Symbol
212
+ def declare_prop(prop, methods)
213
+ others = props.reject { |name, _| name == prop.name }.values
214
+ check_methods(prop, methods)
215
+ check_splat(prop, others)
216
+ check_positional_order(prop, others)
217
+
218
+ own_props[prop.name] = prop
219
+ define_props_initializer
220
+ define_prop_methods(prop, methods)
221
+ prop.name
222
+ end
223
+
224
+ #: (Prop, Symbol) -> Symbol
225
+ def define_prop_method(prop, option)
226
+ ivar = :"@#{prop.name}"
227
+
228
+ case option
229
+ when :reader then props_module.define_method(prop.name) { instance_variable_get(ivar) }
230
+ when :predicate then props_module.define_method(:"#{prop.name}?") { !!instance_variable_get(ivar) }
231
+ else define_prop_writer(prop)
232
+ end
233
+ end
234
+
235
+ # Defines the reader, writer and predicate asked for, each with its own visibility.
236
+ #: (Prop, Hash[Symbol, Symbol | false]) -> void
237
+ def define_prop_methods(prop, methods)
238
+ methods.each do |option, visibility|
239
+ props_module.send(visibility, define_prop_method(prop, option)) if visibility
240
+ end
241
+ end
242
+
243
+ # A writer runs the value through the type, as `initialize` does.
244
+ #: (Prop) -> Symbol
245
+ def define_prop_writer(prop)
246
+ ivar = :"@#{prop.name}"
247
+
248
+ props_module.define_method(:"#{prop.name}=") do |value|
249
+ instance_variable_set(ivar, prop.cast(self.class, value))
250
+ end
251
+ end
252
+
113
253
  # Writes `initialize` into a module of its own rather than onto the class, so a class can
114
254
  # still define `initialize` and call `super`.
115
255
  #: () -> void
116
256
  def define_props_initializer
117
- @props_initializer ||= ::Module.new.tap { |initializer| include(initializer) }
118
- keywords = props.each_value.map { |prop| prop.optional? ? "#{prop.name}: #{UNSET_PATH}" : "#{prop.name}:" }
119
- signature = keywords.join(", ")
257
+ signature = props.values.sort_by.with_index { |prop, index| [KINDS.index(prop.kind), index] }.map(&:parameter)
120
258
 
121
- # `binding` rather than the names themselves, because a prop may be named after a
259
+ # `binding` rather than the names themselves, because a keyword may be named after a
122
260
  # reserved word such as `class`, which is a valid keyword but not a readable variable.
123
- @props_initializer.module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
124
- def initialize(#{signature}) # def initialize(post:, compact: ::Phlex::Hanami::Props::UNSET)
125
- ::Phlex::Hanami::Props.assign(self, binding) # ::Phlex::Hanami::Props.assign(self, binding)
126
- end # end
261
+ props_module.module_eval(<<~RUBY, __FILE__, __LINE__ + 1)
262
+ def initialize(#{signature.join(', ')}) # def initialize(post:, compact: ::Phlex::Hanami::Props::UNSET)
263
+ ::Phlex::Hanami::Props.assign(self, binding) # ::Phlex::Hanami::Props.assign(self, binding)
264
+ after_initialize if respond_to?(:after_initialize, true) # after_initialize if respond_to?(:after_initialize, true)
265
+ end # end
127
266
  RUBY
128
267
  end
129
268
 
@@ -131,59 +270,122 @@ module Phlex
131
270
  def own_props
132
271
  @own_props ||= {}
133
272
  end
273
+
274
+ #: () -> Module
275
+ def props_module
276
+ @props_module ||= ::Module.new.tap { |props_module| include(props_module) }
277
+ end
134
278
  end
135
279
 
136
280
  # One declared prop.
137
281
  #
138
282
  # @api private
139
- # @since 0.3.0
283
+ # @since 0.2.1
140
284
  class Prop
141
285
  attr_reader :name #: Symbol
142
286
 
143
- #: (Symbol, untyped, untyped) -> void
144
- def initialize(name, type, default)
287
+ attr_reader :kind #: Symbol
288
+
289
+ #: (Symbol, untyped, Symbol, default: untyped, omittable: bool, coercion: Proc?) -> void
290
+ def initialize(name, type, kind, default:, omittable:, coercion:)
291
+ raise ArgumentError, "#{name.inspect} is not a valid prop name" unless NAME_FORMAT.match?(name.to_s)
292
+ raise ArgumentError, "kind must be one of #{KINDS.map(&:inspect).join(', ')}" unless KINDS.include?(kind)
293
+ raise ArgumentError, "a #{kind.inspect} prop cannot have a default" if splat?(kind) && !UNSET.equal?(default)
294
+
145
295
  @name = name
146
296
  @type = type
297
+ @kind = kind
147
298
  @default = default
299
+ @omittable = omittable
300
+ @coercion = coercion
301
+ end
302
+
303
+ # Runs a value through the type.
304
+ #
305
+ # @raise [InvalidPropError] if the type rejects the value
306
+ #
307
+ #: (Module, untyped) -> untyped
308
+ def cast(view_class, value)
309
+ if @type.respond_to?(:call)
310
+ begin
311
+ return @type.call(value)
312
+ rescue StandardError => e
313
+ raise InvalidPropError.new(view_class, @name, e.message)
314
+ end
315
+ end
316
+
317
+ return value if @type === value # rubocop:disable Style/CaseEquality
318
+
319
+ raise InvalidPropError.new(view_class, @name, "#{value.inspect} is not a #{@type.inspect}")
148
320
  end
149
321
 
150
- # Whether the keyword can be left out.
322
+ # Whether the argument can be left out.
151
323
  #
152
324
  #: () -> bool
153
325
  def optional?
154
- !UNSET.equal?(@default) || type_default?
326
+ @omittable || splat?(@kind) || !UNSET.equal?(@default) || type_default? || nilable?
327
+ end
328
+
329
+ # This prop's part of the generated initializer's signature.
330
+ #
331
+ #: () -> String
332
+ def parameter
333
+ case @kind
334
+ when :keyword then optional? ? "#{@name}: #{UNSET_PATH}" : "#{@name}:"
335
+ when :positional then optional? ? "#{variable} = #{UNSET_PATH}" : variable.to_s
336
+ else "#{@kind}#{variable}"
337
+ end
155
338
  end
156
339
 
157
340
  # The value to assign, given what the caller passed or {UNSET}.
158
341
  #
159
342
  # @raise [InvalidPropError] if the type rejects the value
160
343
  #
161
- #: (Module, untyped) -> untyped
162
- def resolve(view_class, value)
344
+ #: (untyped, untyped) -> untyped
345
+ def resolve(view, value)
163
346
  if UNSET.equal?(value)
164
- return @type.call if UNSET.equal?(@default)
347
+ return UNSET if @omittable
348
+ return @type.call if UNSET.equal?(@default) && type_default?
165
349
 
166
- value = @default.is_a?(::Proc) ? @default.call : @default
350
+ value = default_value(view)
167
351
  end
168
352
 
169
- cast(view_class, value)
353
+ value = view.instance_exec(value, &@coercion) if @coercion
354
+ cast(view.class, value)
355
+ end
356
+
357
+ # The local variable the generated initializer receives the value in. A keyword keeps its
358
+ # name, since auto render reads it from the signature. Any other argument gets one that
359
+ # cannot clash with a reserved word.
360
+ #
361
+ #: () -> Symbol
362
+ def variable
363
+ @kind == :keyword ? @name : :"__#{@name}__"
170
364
  end
171
365
 
172
366
  private
173
367
 
174
- #: (Module, untyped) -> untyped
175
- def cast(view_class, value)
176
- if @type.respond_to?(:call)
177
- begin
178
- return @type.call(value)
179
- rescue StandardError => e
180
- raise InvalidPropError.new(view_class, @name, e.message)
181
- end
182
- end
368
+ # A prop with no default of its own is optional only because its type accepts nil.
369
+ #: (untyped) -> untyped
370
+ def default_value(view)
371
+ return nil if UNSET.equal?(@default)
183
372
 
184
- return value if @type === value # rubocop:disable Style/CaseEquality
373
+ @default.is_a?(::Proc) ? view.instance_exec(&@default) : @default
374
+ end
185
375
 
186
- raise InvalidPropError.new(view_class, @name, "#{value.inspect} is not a #{@type.inspect}")
376
+ # A dry type built with `.optional`, or anything else that matches nil. A callable is left
377
+ # out of the second test, since `===` on a Proc calls it.
378
+ #: () -> bool
379
+ def nilable?
380
+ return @type.optional? if @type.respond_to?(:optional?)
381
+ return false if @type.respond_to?(:call)
382
+
383
+ @type === nil # rubocop:disable Style/CaseEquality, Style/NilComparison
384
+ end
385
+
386
+ #: (Symbol) -> bool
387
+ def splat?(kind)
388
+ %i[* **].include?(kind)
187
389
  end
188
390
 
189
391
  # A dry type built with `.default` fills in a missing value itself.
@@ -140,15 +140,15 @@ module Phlex
140
140
  input.slice(*parameters.filter_map { |type, name| name if KEYWORD_TYPES.include?(type) })
141
141
  end
142
142
 
143
- # The slice's conventional layout: `Views::Layout` in the slice's namespace, if it defines
144
- # one. Resolved on each call rather than memoized, so code reloading is not defeated.
143
+ # The slice's conventional layout: `views.layout` in its container, if it has one, under
144
+ # whatever base the slice pairs action views with. Resolved on each call rather than
145
+ # memoized, so code reloading is not defeated.
145
146
  #: () -> singleton(::Phlex::SGML)?
146
147
  def default_layout
147
148
  return nil unless slice
148
- return nil unless slice.namespace.const_defined?(:Views, false)
149
149
 
150
- views = slice.namespace.const_get(:Views, false)
151
- views.const_get(:Layout, false) if views.const_defined?(:Layout, false)
150
+ key = Extensions::Slice.view_key(slice, "layout")
151
+ slice[key] if slice.key?(key)
152
152
  end
153
153
  end
154
154
  end
@@ -69,9 +69,12 @@ module Phlex
69
69
  slice["i18n"] if slice.key?("i18n")
70
70
  end
71
71
 
72
+ # Resolves from the app, not the slice. A slice with its own routes file registers its own
73
+ # "routes", which knows only that slice's names and none of the prefix it is mounted at.
74
+ # Actions resolve the app's, so this keeps a view and an action in the same slice in step.
72
75
  #: () -> ::Hanami::Slice::RoutesHelper?
73
76
  def resolve_routes
74
- slice["routes"] if slice.key?("routes")
77
+ slice.app["routes"] if slice.app.key?("routes")
75
78
  end
76
79
  end
77
80
  end
@@ -14,6 +14,8 @@ module Phlex
14
14
 
15
15
  def self.view_context_class: (singleton(::Hanami::Slice)) -> (singleton(::Hanami::View::Context) | singleton(Context))
16
16
 
17
+ def self.view_key: (singleton(::Hanami::Slice), String) -> String
18
+
17
19
  private def self.views_namespace: (singleton(::Hanami::Slice)) -> Module
18
20
  end
19
21
  end
@@ -9,34 +9,68 @@ module Phlex
9
9
 
10
10
  NAME_FORMAT: Regexp
11
11
 
12
+ KINDS: Array[Symbol]
13
+
14
+ VISIBILITIES: Array[Symbol | false]
15
+
12
16
  def self.assign: (untyped, Binding) -> void
13
17
 
14
18
  def self.included: (Module) -> void
15
19
 
16
20
  module ClassMethods
17
- def prop: (Symbol, untyped, ?default: untyped) -> Symbol
21
+ def prop: (Symbol name, untyped type, ?Symbol kind, ?default: untyped, ?reader: Symbol | false, ?writer: Symbol | false, ?predicate: Symbol | false) ?{ (untyped) -> untyped } -> Symbol
22
+
23
+ def prop?: (Symbol name, untyped type, ?Symbol kind, ?reader: Symbol | false, ?writer: Symbol | false, ?predicate: Symbol | false) ?{ (untyped) -> untyped } -> Symbol
18
24
 
19
25
  def props: () -> Hash[Symbol, Prop]
20
26
 
21
27
  private
22
28
 
29
+ def check_methods: (Prop, Hash[Symbol, Symbol | false]) -> void
30
+
31
+ def check_positional_order: (Prop, Array[Prop]) -> void
32
+
33
+ def check_splat: (Prop, Array[Prop]) -> void
34
+
35
+ def declare_prop: (Prop, Hash[Symbol, Symbol | false]) -> Symbol
36
+
37
+ def define_prop_method: (Prop, Symbol) -> Symbol
38
+
39
+ def define_prop_writer: (Prop) -> Symbol
40
+
41
+ def define_prop_methods: (Prop, Hash[Symbol, Symbol | false]) -> void
42
+
23
43
  def define_props_initializer: () -> void
24
44
 
25
45
  def own_props: () -> Hash[Symbol, Prop]
46
+
47
+ def props_module: () -> Module
26
48
  end
27
49
 
28
50
  class Prop
29
51
  attr_reader name: Symbol
30
52
 
31
- def initialize: (Symbol, untyped, untyped) -> void
53
+ attr_reader kind: Symbol
54
+
55
+ def initialize: (Symbol, untyped, Symbol, default: untyped, omittable: bool, coercion: Proc?) -> void
56
+
57
+ def cast: (Module, untyped) -> untyped
32
58
 
33
59
  def optional?: () -> bool
34
60
 
35
- def resolve: (Module, untyped) -> untyped
61
+ def parameter: () -> String
62
+
63
+ def resolve: (untyped, untyped) -> untyped
64
+
65
+ def variable: () -> Symbol
36
66
 
37
67
  private
38
68
 
39
- def cast: (Module, untyped) -> untyped
69
+ def default_value: (untyped) -> untyped
70
+
71
+ def nilable?: () -> bool
72
+
73
+ def splat?: (Symbol) -> bool
40
74
 
41
75
  def type_default?: () -> bool
42
76
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-hanami
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Allen
@@ -141,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
141
  - !ruby/object:Gem::Version
142
142
  version: '0'
143
143
  requirements: []
144
- rubygems_version: 4.0.16
144
+ rubygems_version: 4.0.20
145
145
  specification_version: 4
146
146
  summary: A Phlex adapter for Hanami
147
147
  test_files: []