phlex-hanami 0.2.0 → 0.2.1

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: 94b5188b274ac90980037b58873c197a1ce6fc343d184e9302b41082426fc57b
4
- data.tar.gz: 8cdb0e7203df09112ec77a082e6c727520746e25cd3345071c873722c1588af0
3
+ metadata.gz: 301d34d14bd2989ab0ab899fd6f76e38b5a8e8b1a3fcffa182f6ece292652e81
4
+ data.tar.gz: 74e00aebe59f62800ad37bd576ab97f71efbfbabb536d5fa115e8b2ce5789509
5
5
  SHA512:
6
- metadata.gz: 4ba875c6722dc031fd2911c5e479295b31cc5919b3f881b5f969c32cbcd3797e3b9e87145315aff2289d07fef2d8bcdf04b5dfdce020eeac1e10b914c2bd4211
7
- data.tar.gz: 14f5c3441510387fee8927a7eed2722997fc4a6cb3bf81d3e803075d0083dbdf6747c1e670ac5eac069cb9c0fc2fea6e3f8c6960bcfcc10594e08f8fe46586a0
6
+ metadata.gz: 84d0aec75d54b2e1e7be95d57c9d2c7e9afb560abd4ec7b86065136618775988a33776da0195c71222f9921c628c7b3b6168ca8cc84710f8a18709114210d6a6
7
+ data.tar.gz: 8ab7a57fac797d8daa565d6335ed9efbe2b1cfde7c0f67f30ab98a5c8c01341ee6a2b46ebb0eced44807084080fc6f5d73bec15914f16780163143f4b1b13cc3
data/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [v0.2.1] - 2026-09-26
11
+
12
+ ### Added
13
+
14
+ - `Phlex::Hanami::Props`, an opt in `prop` for views and components typed with dry-types. The type coerces as
15
+ well as checks, so a view can turn a request param into an Integer. Literal still works as before.
16
+
10
17
  ## [v0.2.0] - 2026-09-10
11
18
 
12
19
  First stable release by the new maintainer. It shares no code with 0.1.0, so treat an upgrade from 0.1.0 as a move
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Hanami
5
+ # Raised when a prop's type rejects the value it was given.
6
+ #
7
+ # The type's own error, when there is one, is kept as the `cause`.
8
+ #
9
+ # @api public
10
+ # @since 0.3.0
11
+ class InvalidPropError < Error
12
+ # @api private
13
+ # @since 0.3.0
14
+ #: (Module, Symbol, String) -> void
15
+ def initialize(view_class, name, reason)
16
+ super(<<~MESSAGE)
17
+ #{view_class} was given an invalid #{name.inspect} prop.
18
+
19
+ #{reason}
20
+ MESSAGE
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Hanami
5
+ # Declared props, typed with dry-types.
6
+ #
7
+ # Opt in per class. Each `prop` names a keyword `initialize` takes and the type its value goes
8
+ # through, and the value lands in an instance variable of the same name. A dry type is called,
9
+ # so it coerces as well as checks: `Types::Params::Integer` turns the string a request param
10
+ # arrives as into an Integer. Anything else that answers `call` is called the same way, and
11
+ # anything that does not, such as a plain class, is matched with `===`.
12
+ #
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`.
15
+ #
16
+ # Literal needs none of this. Extend `Literal::Properties` instead if you would rather use it.
17
+ #
18
+ # @example
19
+ # class Card < Phlex::Hanami::Component
20
+ # include Phlex::Hanami::Props
21
+ #
22
+ # prop :post, Types::Instance(Post)
23
+ # prop :count, Types::Params::Integer
24
+ # prop :compact, Types::Bool, default: false
25
+ # prop :tags, Types::Array.of(Types::String), default: -> { [] }
26
+ #
27
+ # def view_template
28
+ # article(class: ("compact" if @compact)) { h2 { @post.title } }
29
+ # end
30
+ # end
31
+ #
32
+ # @api public
33
+ # @since 0.3.0
34
+ module Props
35
+ # Stands in for a keyword the caller left out, since `nil` can be a real value.
36
+ #
37
+ # @api private
38
+ # @since 0.3.0
39
+ UNSET = ::Object.new.freeze #: Object
40
+
41
+ # How the generated initializer names {UNSET}.
42
+ #
43
+ # @api private
44
+ # @since 0.3.0
45
+ UNSET_PATH = "::Phlex::Hanami::Props::UNSET" #: String
46
+
47
+ # A prop name has to be a Ruby identifier, because it becomes a keyword and an instance
48
+ # variable.
49
+ #
50
+ # @api private
51
+ # @since 0.3.0
52
+ NAME_FORMAT = /\A[a-z_][a-zA-Z0-9_]*\z/ #: Regexp
53
+
54
+ # Sets each prop's instance variable from the keywords the generated initializer received.
55
+ #
56
+ # @api private
57
+ # @since 0.3.0
58
+ #: (untyped, Binding) -> void
59
+ def self.assign(view, arguments)
60
+ view.class.props.each_value do |prop|
61
+ value = prop.resolve(view.class, arguments.local_variable_get(prop.name))
62
+ view.instance_variable_set(:"@#{prop.name}", value)
63
+ end
64
+ end
65
+
66
+ # @api private
67
+ # @since 0.3.0
68
+ #: (Module) -> void
69
+ def self.included(view_class)
70
+ view_class.extend(ClassMethods)
71
+ end
72
+
73
+ # @api public
74
+ # @since 0.3.0
75
+ module ClassMethods
76
+ # Declares a prop.
77
+ #
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.
81
+ #
82
+ # @param name [Symbol] the keyword, and the instance variable the value lands in
83
+ # @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
85
+ #
86
+ # @return [Symbol] the name
87
+ #
88
+ # @raise [ArgumentError] if the name is not a Ruby identifier
89
+ #
90
+ # @api public
91
+ # @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)
95
+
96
+ own_props[name] = Prop.new(name, type, default)
97
+ define_props_initializer
98
+ name
99
+ end
100
+
101
+ # Every prop this class declares, its superclasses' first.
102
+ #
103
+ # @api public
104
+ # @since 0.3.0
105
+ #: () -> Hash[Symbol, Prop]
106
+ def props
107
+ inherited = superclass.respond_to?(:props) ? superclass.props : {} #: Hash[Symbol, Prop]
108
+ inherited.merge(own_props)
109
+ end
110
+
111
+ private
112
+
113
+ # Writes `initialize` into a module of its own rather than onto the class, so a class can
114
+ # still define `initialize` and call `super`.
115
+ #: () -> void
116
+ 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(", ")
120
+
121
+ # `binding` rather than the names themselves, because a prop may be named after a
122
+ # 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
127
+ RUBY
128
+ end
129
+
130
+ #: () -> Hash[Symbol, Prop]
131
+ def own_props
132
+ @own_props ||= {}
133
+ end
134
+ end
135
+
136
+ # One declared prop.
137
+ #
138
+ # @api private
139
+ # @since 0.3.0
140
+ class Prop
141
+ attr_reader :name #: Symbol
142
+
143
+ #: (Symbol, untyped, untyped) -> void
144
+ def initialize(name, type, default)
145
+ @name = name
146
+ @type = type
147
+ @default = default
148
+ end
149
+
150
+ # Whether the keyword can be left out.
151
+ #
152
+ #: () -> bool
153
+ def optional?
154
+ !UNSET.equal?(@default) || type_default?
155
+ end
156
+
157
+ # The value to assign, given what the caller passed or {UNSET}.
158
+ #
159
+ # @raise [InvalidPropError] if the type rejects the value
160
+ #
161
+ #: (Module, untyped) -> untyped
162
+ def resolve(view_class, value)
163
+ if UNSET.equal?(value)
164
+ return @type.call if UNSET.equal?(@default)
165
+
166
+ value = @default.is_a?(::Proc) ? @default.call : @default
167
+ end
168
+
169
+ cast(view_class, value)
170
+ end
171
+
172
+ private
173
+
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
183
+
184
+ return value if @type === value # rubocop:disable Style/CaseEquality
185
+
186
+ raise InvalidPropError.new(view_class, @name, "#{value.inspect} is not a #{@type.inspect}")
187
+ end
188
+
189
+ # A dry type built with `.default` fills in a missing value itself.
190
+ #: () -> bool
191
+ def type_default?
192
+ @type.respond_to?(:default?) && @type.default?
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -0,0 +1,9 @@
1
+ # Generated from lib/phlex/hanami/errors/invalid_prop_error.rb with RBS::Inline
2
+
3
+ module Phlex
4
+ module Hanami
5
+ class InvalidPropError < Error
6
+ def initialize: (Module, Symbol, String) -> void
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,45 @@
1
+ # Generated from lib/phlex/hanami/props.rb with RBS::Inline
2
+
3
+ module Phlex
4
+ module Hanami
5
+ module Props
6
+ UNSET: Object
7
+
8
+ UNSET_PATH: String
9
+
10
+ NAME_FORMAT: Regexp
11
+
12
+ def self.assign: (untyped, Binding) -> void
13
+
14
+ def self.included: (Module) -> void
15
+
16
+ module ClassMethods
17
+ def prop: (Symbol, untyped, ?default: untyped) -> Symbol
18
+
19
+ def props: () -> Hash[Symbol, Prop]
20
+
21
+ private
22
+
23
+ def define_props_initializer: () -> void
24
+
25
+ def own_props: () -> Hash[Symbol, Prop]
26
+ end
27
+
28
+ class Prop
29
+ attr_reader name: Symbol
30
+
31
+ def initialize: (Symbol, untyped, untyped) -> void
32
+
33
+ def optional?: () -> bool
34
+
35
+ def resolve: (Module, untyped) -> untyped
36
+
37
+ private
38
+
39
+ def cast: (Module, untyped) -> untyped
40
+
41
+ def type_default?: () -> bool
42
+ end
43
+ end
44
+ end
45
+ 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.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aaron Allen
@@ -68,6 +68,7 @@ files:
68
68
  - lib/phlex/hanami/context.rb
69
69
  - lib/phlex/hanami/contextual.rb
70
70
  - lib/phlex/hanami/errors/error.rb
71
+ - lib/phlex/hanami/errors/invalid_prop_error.rb
71
72
  - lib/phlex/hanami/errors/mailer_view_error.rb
72
73
  - lib/phlex/hanami/errors/missing_context_error.rb
73
74
  - lib/phlex/hanami/errors/relative_path_error.rb
@@ -80,6 +81,7 @@ files:
80
81
  - lib/phlex/hanami/mailer/renderable.rb
81
82
  - lib/phlex/hanami/mailer/text.rb
82
83
  - lib/phlex/hanami/mailer/view.rb
84
+ - lib/phlex/hanami/props.rb
83
85
  - lib/phlex/hanami/renderable.rb
84
86
  - lib/phlex/hanami/rspec.rb
85
87
  - lib/phlex/hanami/slice_configured.rb
@@ -96,6 +98,7 @@ files:
96
98
  - sig/phlex/hanami/context.rbs
97
99
  - sig/phlex/hanami/contextual.rbs
98
100
  - sig/phlex/hanami/errors/error.rbs
101
+ - sig/phlex/hanami/errors/invalid_prop_error.rbs
99
102
  - sig/phlex/hanami/errors/mailer_view_error.rbs
100
103
  - sig/phlex/hanami/errors/missing_context_error.rbs
101
104
  - sig/phlex/hanami/errors/relative_path_error.rbs
@@ -108,6 +111,7 @@ files:
108
111
  - sig/phlex/hanami/mailer/renderable.rbs
109
112
  - sig/phlex/hanami/mailer/text.rbs
110
113
  - sig/phlex/hanami/mailer/view.rbs
114
+ - sig/phlex/hanami/props.rbs
111
115
  - sig/phlex/hanami/renderable.rbs
112
116
  - sig/phlex/hanami/rspec.rbs
113
117
  - sig/phlex/hanami/slice_configured.rbs