phlex-hanami 0.1.0 → 0.2.0.pre.alpha.2

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.
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Hanami
5
+ # Makes a Phlex class renderable by Hanami.
6
+ #
7
+ # Include this into an existing `Phlex::SGML` subclass, or subclass {View}, which includes it
8
+ # already. It gives the class the `call` contract Hanami's `Response#render` expects, per-slice
9
+ # configuration, and access to the request's view context.
10
+ #
11
+ # @example
12
+ # module MyApp
13
+ # class View < Phlex::HTML
14
+ # include Phlex::Hanami::Renderable
15
+ # end
16
+ # end
17
+ #
18
+ # @api public
19
+ # @since 0.2.0
20
+ module Renderable
21
+ # The `Method#parameters` types that name a keyword argument.
22
+ #
23
+ # @api private
24
+ # @since 0.2.0
25
+ KEYWORD_TYPES = %i[key keyreq].freeze
26
+
27
+ # Distinguishes "no layout was configured" from "a layout of `nil` was configured", which is
28
+ # how a view opts out.
29
+ #
30
+ # @api private
31
+ # @since 0.2.0
32
+ UNSET = ::Object.new.freeze
33
+
34
+ # @api private
35
+ # @since 0.2.0
36
+ def self.included(view_class)
37
+ view_class.extend(::Hanami::SliceConfigurable)
38
+ view_class.extend(ClassMethods)
39
+ return unless defined?(::Hanami::Helpers::I18nHelper)
40
+
41
+ # Order matters: a module included later sits higher in the lookup chain, so the overrides
42
+ # have to go in after Hanami's helper, not alongside it in this module.
43
+ view_class.include(::Hanami::Helpers::I18nHelper)
44
+ view_class.include(I18nOverrides)
45
+ end
46
+
47
+ # Relative-key resolution for Hanami's i18n helper.
48
+ #
49
+ # A Phlex view has no template, so Hanami's implementation — which reads
50
+ # `_context.current_template_name` — cannot work. Included after `Hanami::Helpers::I18nHelper`
51
+ # so that it wins: a module included later sits higher in the lookup chain.
52
+ #
53
+ # @api private
54
+ # @since 0.2.0
55
+ module I18nOverrides
56
+ private
57
+
58
+ # Resolves a relative translation key (`t(".title")`) against the view's own container key, so
59
+ # `MyApp::Views::Posts::Index` looks up `posts.index.title`.
60
+ #
61
+ # Hanami's own implementation uses `_context.current_template_name`, which is hanami-view's
62
+ # template stack and has no meaning for a Phlex view.
63
+ def _resolve_i18n_key(key)
64
+ return key unless key.to_s.start_with?(".")
65
+
66
+ base = i18n_key_base
67
+ unless base
68
+ raise ::I18n::ArgumentError,
69
+ "Cannot resolve the relative translation key #{key.inspect} for #{self.class}. " \
70
+ "Give the view a name inside a slice, or use an absolute key (without a leading dot)."
71
+ end
72
+
73
+ "#{base}#{key}"
74
+ end
75
+
76
+ # The view's container key, dotted: `MyApp::Views::Posts::Index` in the app slice becomes
77
+ # `posts.index`. Nil for an anonymous view, or one outside any slice.
78
+ def i18n_key_base
79
+ view_slice = self.class.slice
80
+ name = self.class.name
81
+ return nil unless view_slice && name
82
+
83
+ view_slice.inflector
84
+ .underscore(name)
85
+ .sub(%r{^#{view_slice.slice_name.path}/}, "")
86
+ .sub(%r{^views/}, "")
87
+ .tr("/", ".")
88
+ end
89
+ end
90
+
91
+ # @api public
92
+ # @since 0.2.0
93
+ module ClassMethods
94
+ # Renders the view to a String.
95
+ #
96
+ # This is the entry point Hanami's `Hanami::Action::Response#render` calls. Hanami passes
97
+ # the request's view context as `context:` alongside every response exposure and request
98
+ # param; `input` is filtered down to the keywords this view's `initialize` accepts, so an
99
+ # unexpected param is dropped rather than raising.
100
+ #
101
+ # Exposures take precedence over params, which is Hanami's own merge order.
102
+ #
103
+ # When the view has a layout, the view's body is rendered first and then handed to the
104
+ # layout. That ordering is what makes `content_for` set in the view visible in the layout's
105
+ # `head`; Hanami assigns the whole response body at once, so nothing is streamed either way.
106
+ #
107
+ # @param context [Object, nil] the Hanami view context
108
+ # @param input [Hash] exposures and params
109
+ #
110
+ # @return [String] the rendered markup
111
+ #
112
+ # @api public
113
+ # @since 0.2.0
114
+ def call(context: nil, **input)
115
+ phlex_context = { CONTEXT_KEY => context }
116
+ body = new(**accepted_input(input)).call(context: phlex_context)
117
+
118
+ layout_class = layout
119
+ return body unless layout_class
120
+
121
+ layout_class.new.call(context: phlex_context) { |layout| layout.raw(layout.safe(body)) }
122
+ end
123
+
124
+ # @api private
125
+ # @since 0.2.0
126
+ def configure_for_slice(slice)
127
+ extend SliceConfigured.new(slice)
128
+ end
129
+
130
+ # The layout configured on this class, or inherited from a superclass. {UNSET} when none has
131
+ # been configured anywhere in the chain.
132
+ #
133
+ # @api private
134
+ # @since 0.2.0
135
+ def configured_layout
136
+ return @layout if defined?(@layout)
137
+ return superclass.configured_layout if superclass.respond_to?(:configured_layout)
138
+
139
+ UNSET
140
+ end
141
+
142
+ # Reads or sets the layout this view renders inside.
143
+ #
144
+ # Called with no argument it reads: an explicit setting on this class, otherwise the
145
+ # nearest superclass's, otherwise the slice's conventional `Views::Layout`.
146
+ #
147
+ # Layouts only apply at this entry point, so a component rendered with `render` is never
148
+ # wrapped — only the view Hanami calls is.
149
+ #
150
+ # @example Set a layout for a whole slice, from its base view
151
+ # module MyApp
152
+ # class View < Phlex::Hanami::View
153
+ # layout MyApp::Views::AdminLayout
154
+ # end
155
+ # end
156
+ #
157
+ # @example Opt out, for a turbo frame or a partial response
158
+ # class Frame < MyApp::View
159
+ # layout nil
160
+ # end
161
+ #
162
+ # @param layout_class [Class, nil] the layout to render inside, or nil for none
163
+ #
164
+ # @return [Class, nil] the layout
165
+ #
166
+ # @api public
167
+ # @since 0.2.0
168
+ def layout(layout_class = UNSET)
169
+ unless UNSET.equal?(layout_class)
170
+ @layout = layout_class
171
+ return layout_class
172
+ end
173
+
174
+ configured = configured_layout
175
+ UNSET.equal?(configured) ? default_layout : configured
176
+ end
177
+
178
+ # The slice this view belongs to, or nil when it is defined outside a slice namespace.
179
+ #
180
+ # @return [Hanami::Slice, nil]
181
+ #
182
+ # @api public
183
+ # @since 0.2.0
184
+ def slice
185
+ nil
186
+ end
187
+
188
+ private
189
+
190
+ # Keeps only the keywords `initialize` declares.
191
+ #
192
+ # A view whose initializer takes `**` opts out and receives everything, request params
193
+ # included.
194
+ def accepted_input(input)
195
+ parameters = instance_method(:initialize).parameters
196
+ return input if parameters.any? { |type, _| type == :keyrest }
197
+
198
+ input.slice(*parameters.filter_map { |type, name| name if KEYWORD_TYPES.include?(type) })
199
+ end
200
+
201
+ # The slice's conventional layout: `Views::Layout` in the slice's namespace, if it defines
202
+ # one. Resolved on each call rather than memoized, so code reloading is not defeated.
203
+ def default_layout
204
+ return nil unless slice
205
+ return nil unless slice.namespace.const_defined?(:Views, false)
206
+
207
+ views = slice.namespace.const_get(:Views, false)
208
+ views.const_get(:Layout, false) if views.const_defined?(:Layout, false)
209
+ end
210
+ end
211
+
212
+ # The URL for an asset.
213
+ #
214
+ # @example
215
+ # script(src: asset_url("app.js"))
216
+ #
217
+ # @param source [String] the asset's source path
218
+ #
219
+ # @return [String] the URL
220
+ #
221
+ # @api public
222
+ # @since 0.2.0
223
+ def asset_url(source)
224
+ assets[source].url
225
+ end
226
+
227
+ # The slice's assets.
228
+ #
229
+ # @api public
230
+ # @since 0.2.0
231
+ def assets
232
+ hanami_context.assets
233
+ end
234
+
235
+ # Stores a string of markup for later use, or reads back what was stored.
236
+ #
237
+ # @api public
238
+ # @since 0.2.0
239
+ def content_for(...)
240
+ hanami_context.content_for(...)
241
+ end
242
+
243
+ # The current request's CSRF token.
244
+ #
245
+ # @return [String]
246
+ #
247
+ # @api public
248
+ # @since 0.2.0
249
+ def csrf_token
250
+ hanami_context.csrf_token
251
+ end
252
+
253
+ # The flash hash for the current request.
254
+ #
255
+ # @api public
256
+ # @since 0.2.0
257
+ def flash
258
+ hanami_context.flash
259
+ end
260
+
261
+ # The Hanami view context for this render.
262
+ #
263
+ # Read from Phlex's user context, which is shared with every component in the render tree, so
264
+ # a component nested any number of levels deep sees the same context.
265
+ #
266
+ # @return [Object] a `Hanami::View::Context` when hanami-view is bundled, otherwise a {Context}
267
+ #
268
+ # @raise [MissingContextError] when the view was rendered without a context
269
+ #
270
+ # @api public
271
+ # @since 0.2.0
272
+ def hanami_context
273
+ context[CONTEXT_KEY] || raise(MissingContextError, self.class)
274
+ end
275
+
276
+ # Whether a Hanami view context is available.
277
+ #
278
+ # @return [Boolean]
279
+ #
280
+ # @api public
281
+ # @since 0.2.0
282
+ def hanami_context?
283
+ !context[CONTEXT_KEY].nil?
284
+ end
285
+
286
+ # The slice's i18n backend.
287
+ #
288
+ # @api public
289
+ # @since 0.2.0
290
+ def i18n
291
+ hanami_context.i18n
292
+ end
293
+
294
+ # The path for a named route.
295
+ #
296
+ # @example
297
+ # a(href: path(:posts)) { "Posts" }
298
+ #
299
+ # @return [String]
300
+ #
301
+ # @api public
302
+ # @since 0.2.0
303
+ def path(...)
304
+ routes.path(...)
305
+ end
306
+
307
+ # The current request.
308
+ #
309
+ # @return [Hanami::Action::Request]
310
+ #
311
+ # @api public
312
+ # @since 0.2.0
313
+ def request
314
+ hanami_context.request
315
+ end
316
+
317
+ # Whether the view is being rendered from within a request.
318
+ #
319
+ # @return [Boolean]
320
+ #
321
+ # @api public
322
+ # @since 0.2.0
323
+ def request?
324
+ hanami_context? && hanami_context.request?
325
+ end
326
+
327
+ # The app's routes helper.
328
+ #
329
+ # @return [Hanami::Slice::RoutesHelper]
330
+ #
331
+ # @api public
332
+ # @since 0.2.0
333
+ def routes
334
+ hanami_context.routes
335
+ end
336
+
337
+ # The session for the current request.
338
+ #
339
+ # @api public
340
+ # @since 0.2.0
341
+ def session
342
+ hanami_context.session
343
+ end
344
+
345
+ # The slice this view belongs to.
346
+ #
347
+ # @return [Hanami::Slice, nil]
348
+ #
349
+ # @api public
350
+ # @since 0.2.0
351
+ def slice
352
+ self.class.slice
353
+ end
354
+
355
+ # The full URL for a named route.
356
+ #
357
+ # Hanami's routes helper returns a `URI`; Phlex writes strings, and rejects anything else as
358
+ # an attribute value, so this hands back the string.
359
+ #
360
+ # @example
361
+ # a(href: url(:posts)) { "Posts" }
362
+ #
363
+ # @return [String]
364
+ #
365
+ # @api public
366
+ # @since 0.2.0
367
+ def url(...)
368
+ routes.url(...).to_s
369
+ end
370
+
371
+ private
372
+
373
+ # Hanami's helper modules reach for the view context under this name.
374
+ def _context
375
+ hanami_context
376
+ end
377
+ end
378
+ end
379
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Hanami
5
+ # Tells a class which slice it belongs to.
6
+ #
7
+ # `Hanami::SliceConfigurable` calls `configure_for_slice` once per slice as classes are defined,
8
+ # so a class in the app namespace is configured for the app and one in a slice namespace is
9
+ # configured for that slice. {Renderable} views and Hanami mailers both extend one of these.
10
+ #
11
+ # @api private
12
+ # @since 0.2.0
13
+ class SliceConfigured < Module
14
+ # The slice this module configures for.
15
+ #
16
+ # @return [Hanami::Slice]
17
+ #
18
+ # @api private
19
+ # @since 0.2.0
20
+ attr_reader :slice
21
+
22
+ # @api private
23
+ # @since 0.2.0
24
+ def initialize(slice)
25
+ super()
26
+ @slice = slice
27
+ end
28
+
29
+ # @api private
30
+ # @since 0.2.0
31
+ def extended(_klass)
32
+ define_slice
33
+ end
34
+
35
+ # @api private
36
+ # @since 0.2.0
37
+ def inspect
38
+ "#<#{self.class.name}[#{slice.name}]>"
39
+ end
40
+
41
+ private
42
+
43
+ def define_slice
44
+ slice = @slice
45
+
46
+ define_method(:slice) { slice }
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Hanami
5
+ # Provides slice-specific dependencies to a {Context} subclass defined in a slice's namespace.
6
+ #
7
+ # Mirrors `Hanami::Extensions::View::SliceConfiguredContext`, so a context built without
8
+ # hanami-view is injected with the same components Hanami would have injected with it.
9
+ #
10
+ # @api private
11
+ # @since 0.2.0
12
+ class SliceConfiguredContext < Module
13
+ # The slice this module configures for.
14
+ #
15
+ # @return [Hanami::Slice]
16
+ #
17
+ # @api private
18
+ # @since 0.2.0
19
+ attr_reader :slice
20
+
21
+ # @api private
22
+ # @since 0.2.0
23
+ def initialize(slice)
24
+ super()
25
+ @slice = slice
26
+ end
27
+
28
+ # @api private
29
+ # @since 0.2.0
30
+ def extended(_context_class)
31
+ define_new
32
+ end
33
+
34
+ # @api private
35
+ # @since 0.2.0
36
+ def inspect
37
+ "#<#{self.class.name}[#{slice.name}]>"
38
+ end
39
+
40
+ private
41
+
42
+ # Defines a `.new` that resolves the slice's components and passes them to `#initialize`.
43
+ def define_new
44
+ dependencies = method(:dependencies)
45
+
46
+ define_method(:new) do |**kwargs|
47
+ super(**dependencies.call.merge(kwargs))
48
+ end
49
+ end
50
+
51
+ # The slice components a context is injected with, resolved fresh for each context so that
52
+ # a container which does not memoize still hands out working objects.
53
+ def dependencies
54
+ { assets: resolve_assets, i18n: resolve_i18n, inflector: slice.inflector, routes: resolve_routes }
55
+ end
56
+
57
+ def resolve_assets
58
+ slice["assets"] if slice.key?("assets")
59
+ end
60
+
61
+ def resolve_i18n
62
+ slice["i18n"] if slice.key?("i18n")
63
+ end
64
+
65
+ def resolve_routes
66
+ slice["routes"] if slice.key?("routes")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Hanami
5
+ # The base class for Phlex views rendered by Hanami.
6
+ #
7
+ # Subclass this for an app-level base view. If you already have your own `Phlex::HTML` base
8
+ # class, include {Renderable} into it instead — the two are equivalent.
9
+ #
10
+ # @example
11
+ # # app/view.rb
12
+ # module MyApp
13
+ # class View < Phlex::Hanami::View
14
+ # end
15
+ # end
16
+ #
17
+ # # app/views/posts/index.rb
18
+ # module MyApp
19
+ # module Views
20
+ # module Posts
21
+ # class Index < MyApp::View
22
+ # def initialize(posts:) = @posts = posts
23
+ #
24
+ # def view_template
25
+ # h1 { "Posts" }
26
+ # ul { @posts.each { |post| li { post.title } } }
27
+ # end
28
+ # end
29
+ # end
30
+ # end
31
+ # end
32
+ #
33
+ # @api public
34
+ # @since 0.2.0
35
+ class View < Phlex::HTML
36
+ include Renderable
37
+ end
38
+ end
39
+ end
data/lib/phlex/hanami.rb CHANGED
@@ -1,14 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "hanami"
3
4
  require "phlex"
4
- require_relative "hanami/version"
5
+ require "zeitwerk"
5
6
 
6
7
  module Phlex
8
+ # An object oriented view layer for Hanami.
9
+ #
10
+ # Requiring this file installs the integration: Phlex classes register in slice containers as
11
+ # classes rather than instances, and a view context is made available to every slice.
12
+ #
13
+ # @api public
14
+ # @since 0.2.0
7
15
  module Hanami
8
- autoload :AssetsHelper, "phlex/hanami/assets_helper"
9
- autoload :ContextHelper, "phlex/hanami/context_helper"
10
- autoload :Helpers, "phlex/hanami/helpers"
11
-
12
- ::Hanami::View::HTML::SafeString.include SGML::SafeObject
16
+ # The key the Hanami view context is stored under in Phlex's user context.
17
+ #
18
+ # Phlex shares its user context with every component in a render tree, so nesting a component
19
+ # any number of levels deep keeps the context reachable.
20
+ #
21
+ # @api private
22
+ # @since 0.2.0
23
+ CONTEXT_KEY = :__hanami_context__
13
24
  end
14
25
  end
26
+
27
+ # Hanami only requires its i18n helper as part of its hanami-view extensions, but the helper itself
28
+ # is written to work without hanami-view, so pull it in directly when the i18n gem is there.
29
+ if Hanami.bundled?("i18n")
30
+ require "i18n"
31
+ require "hanami/helpers/i18n_helper"
32
+ end
33
+
34
+ Zeitwerk::Loader.new.tap do |loader|
35
+ lib_dir = File.join(File.dirname(__FILE__), "hanami")
36
+ loader.tag = "phlex-hanami"
37
+ loader.push_dir(lib_dir, namespace: Phlex::Hanami)
38
+ loader.collapse(File.join(lib_dir, "errors"))
39
+ loader.ignore(__FILE__)
40
+ end.setup
41
+
42
+ # Installs the integration. Referencing the constant is what autoloads it, so this cannot move into
43
+ # the file itself — nothing else would ever refer to it.
44
+ Hanami::Slice::ClassMethods.prepend(Phlex::Hanami::Extensions::Slice)
45
+
46
+ # Mail is a second entry point, and an optional one. Zeitwerk keeps the mail classes unloaded until
47
+ # something names one, but prepending onto `Hanami::Mailer` needs the constant to exist.
48
+ Phlex::Hanami::Extensions::Mailer.install if Hanami.bundled?("hanami-mailer")
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "phlex/hanami"
@@ -0,0 +1,2 @@
1
+ # Signatures mirror the files under `lib`. Declare a type here when you add the
2
+ # code it describes, keeping the path the same: `lib/foo/bar.rb` -> `sig/foo/bar.rbs`.