phlex-hanami 0.2.0.pre.alpha.3 → 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 +44 -1
- 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,48 @@ 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
|
+
|
|
17
|
+
## [v0.2.0] - 2026-09-10
|
|
18
|
+
|
|
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
|
|
20
|
+
to a new gem and start from the [usage docs](https://github.com/aaronmallen/phlex-hanami/tree/main/docs/usage). The
|
|
21
|
+
alpha sections below record each change as it landed. This section gathers them in one place.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `Phlex::Hanami::View` and `Phlex::Hanami::Layout`, the base classes for a view and a layout, and
|
|
26
|
+
`Phlex::Hanami::Renderable` for a `Phlex::HTML` base class you already have.
|
|
27
|
+
- Auto render. An action renders the Phlex view whose container key matches its own, so
|
|
28
|
+
`MyApp::Actions::Posts::Index` renders `MyApp::Views::Posts::Index` with no `response.render` call and no
|
|
29
|
+
configuration. An action with no matching view renders nothing, so a slice can move to Phlex one action at a time.
|
|
30
|
+
- A view receives the exposures and params its `initialize` asks for and drops the rest, so a stray query param does
|
|
31
|
+
not raise. An initializer that takes `**` receives all of it.
|
|
32
|
+
- A layout convention: `Views::Layout` in a slice wraps that slice's views. `layout` sets a different one, and
|
|
33
|
+
`layout nil` opts out. The view renders before its layout, so a `content_for` set in the view shows up in the
|
|
34
|
+
layout's `head`.
|
|
35
|
+
- The view context in every view and every component under it: `path`, `url`, `asset_url`, `content_for`,
|
|
36
|
+
`csrf_token`, `flash`, `session`, `request` and more. A view reads the same whether or not hanami-view is bundled.
|
|
37
|
+
- `t`, which resolves a relative key against the view's container key, so `t(".title")` in
|
|
38
|
+
`MyApp::Views::Posts::Index` looks up `posts.index.title`.
|
|
39
|
+
- `Phlex::Hanami::Helpers`, an opt in for Hanami's helper library, such as `form_for` and `format_number`. It needs
|
|
40
|
+
hanami-view. Their output renders as markup rather than escaped twice, and Phlex keeps its own `raw` and `tag`.
|
|
41
|
+
- `Phlex::Hanami::Component` for everything below a view, `Phlex::Hanami::Contextual` for a component base class you
|
|
42
|
+
already have, and support for [Phlex kits](https://www.phlex.fun/components/kits.html).
|
|
43
|
+
- Phlex views for [hanami-mailer](https://github.com/hanami/mailer), through `Phlex::Hanami::Mailer::View` and
|
|
44
|
+
`Phlex::Hanami::Mailer::Layout`. A mailer renders the view whose container key matches its own, and the gem builds
|
|
45
|
+
the plain text part from the HTML unless the view defines `text_body`.
|
|
46
|
+
- `Phlex::Hanami::Testing::ViewHelpers`, for testing a view or a component without a request, and
|
|
47
|
+
`require "phlex/hanami/rspec"` to include them in every example group tagged `type: :view`.
|
|
48
|
+
- RBS signatures for the public API, shipped in `sig`.
|
|
49
|
+
- Usage docs for every part of the gem, including how it hooks into Hanami and why code reloading needs no extra
|
|
50
|
+
work.
|
|
51
|
+
|
|
10
52
|
## [v0.2.0-alpha.3] - 2026-09-06
|
|
11
53
|
|
|
12
54
|
### Added
|
|
@@ -66,7 +108,8 @@ Initial alpha release by the new maintainer [@aaronmallen](https://github.com/aa
|
|
|
66
108
|
|
|
67
109
|
Initial release, by the previous maintainer [@stephannv](https://github.com/stephannv).
|
|
68
110
|
|
|
69
|
-
[Unreleased]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0
|
|
111
|
+
[Unreleased]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0...HEAD
|
|
112
|
+
[v0.2.0]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0-alpha.3...0.2.0
|
|
70
113
|
[v0.2.0-alpha.3]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0-alpha.2...0.2.0-alpha.3
|
|
71
114
|
[v0.2.0-alpha.2]: https://github.com/aaronmallen/phlex-hanami/compare/0.2.0-alpha.1...0.2.0-alpha.2
|
|
72
115
|
[v0.2.0-alpha.1]: https://github.com/aaronmallen/phlex-hanami/releases/tag/0.2.0-alpha.1
|
|
@@ -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
|