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 +4 -4
- data/CHANGELOG.md +7 -0
- data/lib/phlex/hanami/errors/invalid_prop_error.rb +24 -0
- data/lib/phlex/hanami/props.rb +197 -0
- data/sig/phlex/hanami/errors/invalid_prop_error.rbs +9 -0
- data/sig/phlex/hanami/props.rbs +45 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 301d34d14bd2989ab0ab899fd6f76e38b5a8e8b1a3fcffa182f6ece292652e81
|
|
4
|
+
data.tar.gz: 74e00aebe59f62800ad37bd576ab97f71efbfbabb536d5fa115e8b2ce5789509
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,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.
|
|
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
|