weft 0.1.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 +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +150 -0
- data/docs/app-patterns.md +268 -0
- data/docs/arbre.md +298 -0
- data/docs/configuration.md +219 -0
- data/docs/dsl.md +329 -0
- data/docs/error-handling.md +136 -0
- data/docs/examples/README.md +70 -0
- data/docs/examples/active-search.md +125 -0
- data/docs/examples/browser-dialogs.md +71 -0
- data/docs/examples/bulk-update.md +122 -0
- data/docs/examples/click-to-edit.md +113 -0
- data/docs/examples/click-to-load.md +77 -0
- data/docs/examples/delete-row.md +101 -0
- data/docs/examples/edit-row.md +146 -0
- data/docs/examples/file-upload.md +96 -0
- data/docs/examples/infinite-scroll.md +98 -0
- data/docs/examples/inline-expansion.md +98 -0
- data/docs/examples/inline-validation.md +101 -0
- data/docs/examples/keyboard-shortcuts.md +71 -0
- data/docs/examples/lazy-loading.md +96 -0
- data/docs/examples/live-ticker.md +91 -0
- data/docs/examples/modal-dialog.md +88 -0
- data/docs/examples/progress-bar.md +138 -0
- data/docs/examples/reset-user-input.md +101 -0
- data/docs/examples/tabs.md +102 -0
- data/docs/examples/tooltip.md +88 -0
- data/docs/examples/updating-other-content.md +169 -0
- data/docs/examples/value-select.md +109 -0
- data/docs/routing.md +131 -0
- data/docs/tutorial.md +372 -0
- data/lib/weft/action.rb +83 -0
- data/lib/weft/attributes.rb +65 -0
- data/lib/weft/component.rb +176 -0
- data/lib/weft/configuration.rb +177 -0
- data/lib/weft/context/interception.rb +22 -0
- data/lib/weft/context.rb +184 -0
- data/lib/weft/defaults/error_component.rb +63 -0
- data/lib/weft/defaults/error_page.rb +27 -0
- data/lib/weft/defaults/not_found_component.rb +44 -0
- data/lib/weft/defaults/not_found_page.rb +25 -0
- data/lib/weft/defaults.rb +9 -0
- data/lib/weft/dsl/actions.rb +72 -0
- data/lib/weft/dsl/attributes.rb +43 -0
- data/lib/weft/dsl/containers.rb +77 -0
- data/lib/weft/dsl/inclusions.rb +49 -0
- data/lib/weft/dsl/recoveries.rb +89 -0
- data/lib/weft/dsl/triggers.rb +40 -0
- data/lib/weft/dsl/updates.rb +86 -0
- data/lib/weft/error.rb +64 -0
- data/lib/weft/page.rb +371 -0
- data/lib/weft/redirect.rb +45 -0
- data/lib/weft/registry/eligibility.rb +58 -0
- data/lib/weft/registry.rb +202 -0
- data/lib/weft/resolver.rb +33 -0
- data/lib/weft/router/actions.rb +77 -0
- data/lib/weft/router/errors.rb +246 -0
- data/lib/weft/router/oob_includes.rb +34 -0
- data/lib/weft/router/streaming.rb +63 -0
- data/lib/weft/router.rb +191 -0
- data/lib/weft/shorthands.rb +57 -0
- data/lib/weft/version.rb +5 -0
- data/lib/weft.rb +124 -0
- metadata +169 -0
data/lib/weft/page.rb
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Weft
|
|
6
|
+
# Document shell component. Renders the full HTML skeleton (doctype,
|
|
7
|
+
# head, body) with registered scripts and stylesheets. Subclass to
|
|
8
|
+
# add application-specific assets and CSS.
|
|
9
|
+
#
|
|
10
|
+
# Pages auto-route via page_path declarations or class-name inference.
|
|
11
|
+
# The Router serves them as full documents at the resolved URL patterns.
|
|
12
|
+
#
|
|
13
|
+
# class OrderDetailPage < Weft::Page
|
|
14
|
+
# self.page_path = "/orders/:order_id"
|
|
15
|
+
# attribute :order_id
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Subclasses without an explicit page_path auto-infer one from the class
|
|
19
|
+
# name: the demodulized name, snake-cased, with any trailing "Page" suffix
|
|
20
|
+
# stripped (DashboardPage and Dashboard both route at "/dashboard"). Use
|
|
21
|
+
# abstract! to opt out — typical for an intermediate base class that hosts
|
|
22
|
+
# shared assets and helpers but isn't itself a destination.
|
|
23
|
+
class Page < Arbre::Component # rubocop:disable Metrics/ClassLength
|
|
24
|
+
extend Weft::Registry::Eligibility
|
|
25
|
+
|
|
26
|
+
include Weft::Context::Interception
|
|
27
|
+
include Weft::DSL::Attributes
|
|
28
|
+
include Weft::DSL::Recoveries
|
|
29
|
+
include Weft::DSL::Containers
|
|
30
|
+
|
|
31
|
+
# @!method weft_page(*args, &block)
|
|
32
|
+
# @api private
|
|
33
|
+
# Arbre builder for the Weft page element. Internal plumbing — authors
|
|
34
|
+
# subclass Weft::Page and render via the Router; they do not call this.
|
|
35
|
+
builder_method :weft_page
|
|
36
|
+
|
|
37
|
+
HTMX_SRC = "https://unpkg.com/htmx.org@2.0.4"
|
|
38
|
+
HTMX_ATTRS = {
|
|
39
|
+
integrity: "sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+",
|
|
40
|
+
crossorigin: "anonymous"
|
|
41
|
+
}.freeze
|
|
42
|
+
HTMX_SSE_SRC = "https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"
|
|
43
|
+
HTMX_SSE_ATTRS = {
|
|
44
|
+
integrity: "sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI",
|
|
45
|
+
crossorigin: "anonymous"
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# Class-level page path pattern. Sinatra-style string with :param segments.
|
|
50
|
+
# Bidirectional: forward (interpolate attrs → URL) and reverse (match request → params).
|
|
51
|
+
#
|
|
52
|
+
# self.page_path = "/orders/:order_id"
|
|
53
|
+
def page_path
|
|
54
|
+
if instance_variable_defined?(:@page_path)
|
|
55
|
+
@page_path
|
|
56
|
+
elsif superclass.respond_to?(:page_path)
|
|
57
|
+
superclass.page_path
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
attr_writer :page_path
|
|
62
|
+
|
|
63
|
+
# Resolve the page path by interpolating attrs into the pattern.
|
|
64
|
+
# OrderDetailPage.resolve_page_path(order_id: "42") # => "/orders/42"
|
|
65
|
+
def resolve_page_path(attrs = {})
|
|
66
|
+
pattern = page_path || default_page_path
|
|
67
|
+
pattern.gsub(/:(\w+)/) { attrs[::Regexp.last_match(1).to_sym] || ":#{::Regexp.last_match(1)}" }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Build a redirect URL targeting this page with the given attrs.
|
|
71
|
+
# Path :param segments interpolate from attrs; declared-but-not-param
|
|
72
|
+
# attrs become query string entries. Anything not in the page's
|
|
73
|
+
# declared schema is discarded — never leaks into the URL.
|
|
74
|
+
#
|
|
75
|
+
# class OrderDetailPage < Weft::Page
|
|
76
|
+
# self.page_path = "/orders/:order_id"
|
|
77
|
+
# attribute :order_id
|
|
78
|
+
# attribute :highlight_section
|
|
79
|
+
# end
|
|
80
|
+
# OrderDetailPage.redirect_url(order_id: 42, highlight_section: "items", junk: "x")
|
|
81
|
+
# # => "/orders/42?highlight_section=items"
|
|
82
|
+
def redirect_url(attrs = {})
|
|
83
|
+
path = resolve_page_path(attrs)
|
|
84
|
+
query = attrs.slice(*(attributes.keys - path_param_keys)).compact
|
|
85
|
+
query.empty? ? path : "#{path}?#{::URI.encode_www_form(query)}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def path_param_keys
|
|
89
|
+
pattern = page_path || default_page_path
|
|
90
|
+
pattern.scan(/:(\w+)/).flatten.map(&:to_sym)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Inferred routability from declared state, ignoring any explicit
|
|
94
|
+
# override. Subclasses fall back to this when they have no override of
|
|
95
|
+
# their own, so an abstract parent does not disable concrete children.
|
|
96
|
+
#
|
|
97
|
+
# A page is inferred-routable if it has an explicit page_path, or if its
|
|
98
|
+
# class name yields a usable default — i.e. the demodulized name has a
|
|
99
|
+
# non-empty stem after stripping any trailing "Page" suffix. The suffix
|
|
100
|
+
# is optional: FooBarPage and BazBar both route. Pages with attributes
|
|
101
|
+
# are not inferred-routable; they require an explicit page_path (a
|
|
102
|
+
# parameterized route can't be derived from the name; see default_page_path).
|
|
103
|
+
def inferred_routable?
|
|
104
|
+
return true if instance_variable_defined?(:@page_path)
|
|
105
|
+
return false if attributes.any?
|
|
106
|
+
|
|
107
|
+
!name.to_s.delete_suffix("Page").demodulize.empty?
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def inherited(subclass)
|
|
111
|
+
super
|
|
112
|
+
Weft.registry.register_page(subclass)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Render this page as a full HTML document outside any Arbre DSL context.
|
|
116
|
+
# Used by the Router for full-document responses, and available to users
|
|
117
|
+
# for testing or standalone rendering.
|
|
118
|
+
def render(**attributes)
|
|
119
|
+
klass = self
|
|
120
|
+
Weft::Context.new({}, nil) do
|
|
121
|
+
insert_tag(klass, **attributes)
|
|
122
|
+
end.to_s
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Register a stylesheet to include in the page head.
|
|
126
|
+
#
|
|
127
|
+
# Absolute URLs (http(s)://, protocol-relative //, leading /) render as-is.
|
|
128
|
+
# Bare-relative paths resolve against a static_assets bundle: either the
|
|
129
|
+
# one named via `assets:`, or the :default bundle if one is configured.
|
|
130
|
+
# When neither applies, the path passes through unchanged and the browser
|
|
131
|
+
# resolves it relative to the current page URL.
|
|
132
|
+
#
|
|
133
|
+
# register_stylesheet "https://cdn.example.com/bootstrap.css"
|
|
134
|
+
# register_stylesheet "css/app.css" # resolves against :default
|
|
135
|
+
# register_stylesheet "tailwind/tw.css", assets: :vendor
|
|
136
|
+
def register_stylesheet(href, assets: nil)
|
|
137
|
+
own_stylesheets << { href: href, assets: assets }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Register a script to include in the page head. Same resolution rules
|
|
141
|
+
# as register_stylesheet. Additional kwargs become HTML attributes on
|
|
142
|
+
# the <script> tag.
|
|
143
|
+
#
|
|
144
|
+
# register_script "https://cdn.example.com/app.js", defer: "defer"
|
|
145
|
+
# register_script "js/app.js" # resolves against :default
|
|
146
|
+
# register_script "vendor/x.js", assets: :vendor, defer: "defer"
|
|
147
|
+
def register_script(src, assets: nil, **html_attrs)
|
|
148
|
+
own_scripts << { src: src, attrs: html_attrs, assets: assets }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Register a block of inline CSS to include in the page head.
|
|
152
|
+
# Each registered string emits as its own <style> tag (subclasses
|
|
153
|
+
# add on top of their parent's contributions; nothing replaces).
|
|
154
|
+
#
|
|
155
|
+
# register_css <<~CSS
|
|
156
|
+
# .card { padding: 1rem; }
|
|
157
|
+
# CSS
|
|
158
|
+
def register_css(css)
|
|
159
|
+
own_inline_css << css
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# All registered stylesheets (own + inherited).
|
|
163
|
+
def stylesheets
|
|
164
|
+
if superclass.respond_to?(:stylesheets)
|
|
165
|
+
superclass.stylesheets + own_stylesheets
|
|
166
|
+
else
|
|
167
|
+
own_stylesheets.dup
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# All registered scripts (own + inherited).
|
|
172
|
+
def scripts
|
|
173
|
+
if superclass.respond_to?(:scripts)
|
|
174
|
+
superclass.scripts + own_scripts
|
|
175
|
+
else
|
|
176
|
+
own_scripts.dup
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# All registered inline CSS blocks (own + inherited).
|
|
181
|
+
def inline_css
|
|
182
|
+
if superclass.respond_to?(:inline_css)
|
|
183
|
+
superclass.inline_css + own_inline_css
|
|
184
|
+
else
|
|
185
|
+
own_inline_css.dup
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def own_stylesheets
|
|
192
|
+
@own_stylesheets ||= []
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def own_scripts
|
|
196
|
+
@own_scripts ||= []
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def own_inline_css
|
|
200
|
+
@own_inline_css ||= []
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def default_page_path
|
|
204
|
+
if attributes.any?
|
|
205
|
+
raise Weft::InvalidDefinition,
|
|
206
|
+
"#{name} declares attributes but no explicit page_path. " \
|
|
207
|
+
"Set self.page_path = \"/your/path/:#{attributes.keys.first}\""
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
stem = name.to_s.delete_suffix("Page")
|
|
211
|
+
if stem.demodulize.empty?
|
|
212
|
+
raise Weft::InvalidDefinition,
|
|
213
|
+
"#{name.inspect} has no resolvable default page_path. " \
|
|
214
|
+
"Either rename the class with a meaningful stem (e.g. DashboardPage), " \
|
|
215
|
+
"set self.page_path = \"/your/path\" explicitly, " \
|
|
216
|
+
"or mark the class abstract! if it isn't meant to route."
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
"/#{stem.underscore}"
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def build(attributes = {})
|
|
224
|
+
schema = self.class.attributes
|
|
225
|
+
@attrs = Weft::Attributes.extract_from(attributes, using: schema) if schema.any?
|
|
226
|
+
@page_title = attributes.delete(:title) || "Weft"
|
|
227
|
+
super(attributes.except(*schema.keys))
|
|
228
|
+
build_head
|
|
229
|
+
@body_el = insert_tag(Arbre::HTML::Body)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def tag_name
|
|
233
|
+
"html"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def add_child(child)
|
|
237
|
+
@body_el ? (@body_el << child) : super
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def to_s
|
|
241
|
+
"<!DOCTYPE html>\n#{super}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def build_head
|
|
247
|
+
insert_tag(Arbre::HTML::Head) do
|
|
248
|
+
meta charset: "utf-8"
|
|
249
|
+
meta name: "viewport", content: "width=device-width, initial-scale=1"
|
|
250
|
+
title { text_node @page_title }
|
|
251
|
+
render_assets
|
|
252
|
+
render_htmx_config
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def render_assets
|
|
257
|
+
render_stylesheets
|
|
258
|
+
render_htmx_script if Weft.configuration.include_htmx
|
|
259
|
+
render_sse_script if include_sse_ext?
|
|
260
|
+
render_scripts
|
|
261
|
+
render_inline_css
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def render_stylesheets
|
|
265
|
+
self.class.stylesheets.each do |entry|
|
|
266
|
+
link href: resolve_asset_url(entry[:href], assets: entry[:assets]), rel: "stylesheet"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def render_scripts
|
|
271
|
+
self.class.scripts.each do |entry|
|
|
272
|
+
script(src: resolve_asset_url(entry[:src], assets: entry[:assets]), **entry[:attrs])
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Resolve a registered asset path to a final URL.
|
|
277
|
+
#
|
|
278
|
+
# - Absolute URLs (http(s)://, //, /) pass through unchanged. Passing an
|
|
279
|
+
# `assets:` kwarg alongside an absolute URL raises — the kwarg is only
|
|
280
|
+
# meaningful for relative paths, and accepting it silently would train
|
|
281
|
+
# callers to attach it everywhere "just in case."
|
|
282
|
+
# - Bare-relative paths with an explicit `assets: :name` resolve against
|
|
283
|
+
# that bundle's root. Unknown bundle names raise with a list of
|
|
284
|
+
# configured names.
|
|
285
|
+
# - Bare-relative paths without an `assets:` kwarg resolve against the
|
|
286
|
+
# :default bundle if one is configured; otherwise they pass through and
|
|
287
|
+
# the browser interprets them relative to the current page URL.
|
|
288
|
+
def resolve_asset_url(path, assets: nil)
|
|
289
|
+
return resolve_absolute_asset_url(path, assets) if absolute_asset_url?(path)
|
|
290
|
+
|
|
291
|
+
resolve_relative_asset_url(path, assets)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def resolve_absolute_asset_url(path, assets)
|
|
295
|
+
raise_absolute_with_assets_kwarg!(path, assets) if assets
|
|
296
|
+
|
|
297
|
+
path
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def resolve_relative_asset_url(path, assets)
|
|
301
|
+
bundles = Weft.configuration.static_assets
|
|
302
|
+
target = assets&.to_sym || (bundles.key?(:default) ? :default : nil)
|
|
303
|
+
return path unless target
|
|
304
|
+
|
|
305
|
+
bundle = bundles[target]
|
|
306
|
+
raise_unknown_assets_bundle!(path, target, bundles) unless bundle
|
|
307
|
+
|
|
308
|
+
"#{bundle[:root]}/#{path}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def absolute_asset_url?(path)
|
|
312
|
+
path.start_with?("http://", "https://", "//", "/")
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def raise_absolute_with_assets_kwarg!(path, assets)
|
|
316
|
+
raise Weft::InvalidUsage,
|
|
317
|
+
"static asset #{path.inspect}: assets: #{assets.inspect} " \
|
|
318
|
+
"is only meaningful for relative paths (absolute URLs render as-is)"
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def raise_unknown_assets_bundle!(path, target, bundles)
|
|
322
|
+
raise Weft::InvalidUsage,
|
|
323
|
+
"static asset #{path.inspect} references assets bundle #{target.inspect}, " \
|
|
324
|
+
"but no such bundle is configured. Configured bundles: #{bundles.keys.inspect}"
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def render_inline_css
|
|
328
|
+
self.class.inline_css.each { |css| style { text_node css.html_safe } }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def render_htmx_script
|
|
332
|
+
script src: HTMX_SRC, **HTMX_ATTRS
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def render_sse_script
|
|
336
|
+
script src: HTMX_SSE_SRC, **HTMX_SSE_ATTRS
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Resolve the include_sse_ext configuration into a boolean. :auto defers
|
|
340
|
+
# to the Registry; true/false short-circuit it.
|
|
341
|
+
def include_sse_ext?
|
|
342
|
+
case Weft.configuration.include_sse_ext
|
|
343
|
+
when true then true
|
|
344
|
+
when false then false
|
|
345
|
+
else Weft.registry.any_sse_components?
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def render_htmx_config
|
|
350
|
+
script do
|
|
351
|
+
text_node htmx_config_js.html_safe
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def htmx_config_js
|
|
356
|
+
<<~JS
|
|
357
|
+
htmx.config.responseHandling = [
|
|
358
|
+
{code: "204", swap: false},
|
|
359
|
+
{code: "[23]..", swap: true},
|
|
360
|
+
{code: "[45]..", swap: true, error: true}
|
|
361
|
+
];
|
|
362
|
+
JS
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Gem-default recovery edges. Symbol form defers resolution until
|
|
366
|
+
# error-handling time so reassigning the configuration knob propagates.
|
|
367
|
+
# NotFound declared first so its more-specific match wins over StandardError.
|
|
368
|
+
recovers from: Weft::NotFound, with: :not_found_page
|
|
369
|
+
recovers from: StandardError, with: :error_page
|
|
370
|
+
end
|
|
371
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
# Value object returned from action callables to trigger navigation
|
|
5
|
+
# instead of re-rendering. The Router detects Redirect returns and
|
|
6
|
+
# sends HX-Redirect (htmx) or 302 (traditional form).
|
|
7
|
+
#
|
|
8
|
+
# Preferred form accepts a Page subclass:
|
|
9
|
+
# Weft::Redirect.to(OrderDetailPage, order_id: order.id)
|
|
10
|
+
#
|
|
11
|
+
# String path fallback:
|
|
12
|
+
# Weft::Redirect.to("/orders/#{order.id}")
|
|
13
|
+
#
|
|
14
|
+
# Convenience wrapper:
|
|
15
|
+
# Weft.redirect(OrderDetailPage, order_id: order.id)
|
|
16
|
+
class Redirect
|
|
17
|
+
attr_reader :target, :attrs
|
|
18
|
+
|
|
19
|
+
# @api private
|
|
20
|
+
# Use {Redirect.to} (or {Weft.redirect}) — +new+ is private.
|
|
21
|
+
def initialize(target, **attrs)
|
|
22
|
+
@target = target
|
|
23
|
+
@attrs = attrs
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Resolve the redirect URL.
|
|
27
|
+
# Page targets: interpolate attrs into page_path pattern.
|
|
28
|
+
# String targets: use as-is.
|
|
29
|
+
def url
|
|
30
|
+
case @target
|
|
31
|
+
when String
|
|
32
|
+
@target
|
|
33
|
+
else
|
|
34
|
+
@target.resolve_page_path(@attrs)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Primary constructor.
|
|
39
|
+
def self.to(target, **attrs)
|
|
40
|
+
new(target, **attrs)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private_class_method :new
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
class Registry
|
|
5
|
+
# Class-level routing-eligibility behaviors shared by the base classes that
|
|
6
|
+
# auto-register with {Weft.registry} on definition — {Weft::Component} and
|
|
7
|
+
# {Weft::Page}. Mixed in with +extend+, so these become class methods on
|
|
8
|
+
# those bases and their subclasses, and any individual class may override
|
|
9
|
+
# them. The companion +inferred_routable?+ is defined per base class (its
|
|
10
|
+
# logic differs: components infer from interactive behavior, pages from
|
|
11
|
+
# having a usable path).
|
|
12
|
+
module Eligibility
|
|
13
|
+
# Whether this class auto-routes. An explicit override via {abstract!} or
|
|
14
|
+
# {routable!} takes precedence; otherwise routability is inferred (see the
|
|
15
|
+
# per-class +inferred_routable?+).
|
|
16
|
+
#
|
|
17
|
+
# The override is stored as an instance variable on the declaring class
|
|
18
|
+
# object, so it does not percolate to subclasses — an abstract base can
|
|
19
|
+
# have concrete subclasses that auto-route normally.
|
|
20
|
+
def routable?
|
|
21
|
+
return @routable_explicit if instance_variable_defined?(:@routable_explicit)
|
|
22
|
+
|
|
23
|
+
inferred_routable?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Mark this class as a non-routable abstract base, even if its declared
|
|
27
|
+
# state would otherwise make it routable. Does not percolate to subclasses.
|
|
28
|
+
def abstract!
|
|
29
|
+
@routable_explicit = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Force this class to be routable, even if its declared state would
|
|
33
|
+
# otherwise make it non-routable. Does not percolate to subclasses.
|
|
34
|
+
def routable!
|
|
35
|
+
@routable_explicit = true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether this class object has been superseded — its fully-qualified name
|
|
39
|
+
# now resolves to a *different* class. This is the code-reload case: a
|
|
40
|
+
# reloader (e.g. Zeitwerk in development) redefines the constant, binding a
|
|
41
|
+
# new class object to the name while the old one lingers in the registry.
|
|
42
|
+
# The registry drops superseded classes so only the current definition
|
|
43
|
+
# routes (otherwise the two would look like a route collision).
|
|
44
|
+
#
|
|
45
|
+
# Classes whose name does not resolve to a constant — anonymous classes,
|
|
46
|
+
# or test doubles that stub +.name+ — are never stale. Override for
|
|
47
|
+
# bespoke liveness semantics.
|
|
48
|
+
#
|
|
49
|
+
# @note Uses ActiveSupport's +safe_constantize+, which walks the namespace.
|
|
50
|
+
# The registry calls this only at route-resolution time (memoized), so
|
|
51
|
+
# the cost is paid once per registry generation, not per request.
|
|
52
|
+
def stale?
|
|
53
|
+
current = name&.safe_constantize
|
|
54
|
+
!current.nil? && !equal?(current)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Weft
|
|
4
|
+
# Stores registered Weft::Component and Weft::Page classes for the Router
|
|
5
|
+
# to consume at request time.
|
|
6
|
+
#
|
|
7
|
+
# Components and Pages are stored separately because their lookup costs
|
|
8
|
+
# differ:
|
|
9
|
+
#
|
|
10
|
+
# - **Components** route at static paths (derived from the class name).
|
|
11
|
+
# Stored in `@components`; looked up via `@path_index`, a lazily-built
|
|
12
|
+
# hash keyed on the resolved path. O(1) lookup, invalidated when new
|
|
13
|
+
# components register.
|
|
14
|
+
#
|
|
15
|
+
# - **Pages** route at user-declared patterns that may include `:param`
|
|
16
|
+
# segments (e.g. `/orders/:order_id`). Stored in `@pages`; matched by
|
|
17
|
+
# walking the set and pattern-matching each `page_path` against the
|
|
18
|
+
# request path. O(n) match, but n is typically small (one entry per
|
|
19
|
+
# user-declared Page).
|
|
20
|
+
#
|
|
21
|
+
# Because pages aren't in `@path_index`, `register_page` does not
|
|
22
|
+
# invalidate it. Unifying the two storage shapes would force pages-without-
|
|
23
|
+
# params into the linear walk OR invent two parallel code paths; keeping
|
|
24
|
+
# them separate is the simplest honest answer.
|
|
25
|
+
class Registry
|
|
26
|
+
def initialize
|
|
27
|
+
@components = Set.new
|
|
28
|
+
@pages = Set.new
|
|
29
|
+
@path_index = nil
|
|
30
|
+
@sse_present = nil
|
|
31
|
+
@routes_validated = false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def register(component_class)
|
|
35
|
+
@components.add(component_class)
|
|
36
|
+
@path_index = nil
|
|
37
|
+
@routes_validated = false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# No @path_index invalidation: pages aren't indexed there (see the class
|
|
41
|
+
# comment above). Pages *do* participate in collision detection, so the
|
|
42
|
+
# route-validation memo is cleared.
|
|
43
|
+
def register_page(page_class)
|
|
44
|
+
@pages.add(page_class)
|
|
45
|
+
@routes_validated = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Empty the registry. Provided as an explicit reset hook for app-level
|
|
49
|
+
# reload integrations and for tests; ordinary reloading is handled
|
|
50
|
+
# automatically (superseded classes are pruned — see stale-class handling
|
|
51
|
+
# in validate_routes!).
|
|
52
|
+
def clear
|
|
53
|
+
@components.clear
|
|
54
|
+
@pages.clear
|
|
55
|
+
@path_index = nil
|
|
56
|
+
@sse_present = nil
|
|
57
|
+
@routes_validated = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pages
|
|
61
|
+
@pages.to_a
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Match a request path against registered page patterns.
|
|
65
|
+
# Non-routable pages (abstract bases, classes without a resolvable path)
|
|
66
|
+
# are skipped. Returns [page_class, extracted_params] or nil.
|
|
67
|
+
def match_page(path)
|
|
68
|
+
validate_routes!
|
|
69
|
+
@pages.each do |page_class|
|
|
70
|
+
next unless page_class.routable?
|
|
71
|
+
|
|
72
|
+
params = match_pattern(resolved_page_pattern(page_class), path)
|
|
73
|
+
return [page_class, params] if params
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def lookup(path)
|
|
79
|
+
validate_routes!
|
|
80
|
+
path_index[path]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def components
|
|
84
|
+
@components.to_a
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# True if any registered component declares `pushes` (i.e. participates in
|
|
88
|
+
# SSE streaming). Used by Weft::Page to decide whether to emit the
|
|
89
|
+
# htmx-ext-sse script tag automatically. One-way memoized: recomputed while
|
|
90
|
+
# still false, so components registered after an earlier render are picked
|
|
91
|
+
# up; once true it sticks, so the typical load-everything-then-serve pattern
|
|
92
|
+
# pays no steady-state recompute cost.
|
|
93
|
+
def any_sse_components?
|
|
94
|
+
return true if @sse_present
|
|
95
|
+
|
|
96
|
+
@sse_present = @components.any?(&:push_config)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Routable components only — non-routable components register but are never
|
|
102
|
+
# served, so they don't occupy a route (and must not shadow one in the index).
|
|
103
|
+
def path_index
|
|
104
|
+
@path_index ||= routable_components.to_h { |klass| [klass.resolved_component_path, klass] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def routable_components
|
|
108
|
+
@components.select(&:routable?)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def routable_pages
|
|
112
|
+
@pages.select(&:routable?)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolved_page_pattern(page_class)
|
|
116
|
+
page_class.page_path || page_class.send(:default_page_path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Drop classes whose constant has been redefined out from under them (the
|
|
120
|
+
# code-reload case — see Weft::Registry::Eligibility#stale?). Without this, a
|
|
121
|
+
# reloaded class and its stale predecessor resolve to the same path and look
|
|
122
|
+
# like a route collision. Runs once per registry generation via
|
|
123
|
+
# validate_routes!; @path_index is rebuilt only if a component was removed.
|
|
124
|
+
def prune_stale!
|
|
125
|
+
@path_index = nil if @components.reject!(&:stale?)
|
|
126
|
+
@pages.reject!(&:stale?)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Build the effective-route table across every routable component (its base
|
|
130
|
+
# path plus its reserved stream-suffix tail) and routable page (its resolved
|
|
131
|
+
# pattern), and raise Weft::InvalidDefinition on any duplicate — component vs
|
|
132
|
+
# component, page vs page, or component vs page — or any malformed path.
|
|
133
|
+
# Memoized per registry generation (cleared when a class registers), so it
|
|
134
|
+
# runs once at first request and is a no-op thereafter. Routability gates
|
|
135
|
+
# everything: abstract!/non-routable classes derive no path and never collide.
|
|
136
|
+
def validate_routes!
|
|
137
|
+
return if @routes_validated
|
|
138
|
+
|
|
139
|
+
prune_stale!
|
|
140
|
+
seen = {}
|
|
141
|
+
routable_components.each do |klass|
|
|
142
|
+
base = klass.resolved_component_path
|
|
143
|
+
add_route!(seen, base, klass, :component)
|
|
144
|
+
add_route!(seen, "#{base}/#{Weft.configuration.stream_suffix}", klass, :stream)
|
|
145
|
+
end
|
|
146
|
+
routable_pages.each { |klass| add_route!(seen, resolved_page_pattern(klass), klass, :page) }
|
|
147
|
+
@routes_validated = true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def add_route!(seen, path, klass, kind)
|
|
151
|
+
validate_route_shape!(path, klass, kind)
|
|
152
|
+
if (existing = seen[path])
|
|
153
|
+
raise Weft::InvalidDefinition, collision_message(path, existing, [klass, kind])
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
seen[path] = [klass, kind]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Tier-B well-formedness guard: a resolved route must be a non-empty string
|
|
160
|
+
# beginning with "/" (an explicit "/" homepage is fine). Catches garbage from
|
|
161
|
+
# custom/inherited component_path or page_path procs.
|
|
162
|
+
def validate_route_shape!(path, klass, kind)
|
|
163
|
+
return if path.is_a?(String) && path.start_with?("/")
|
|
164
|
+
|
|
165
|
+
raise Weft::InvalidDefinition,
|
|
166
|
+
"#{route_label(klass, kind)} resolves to #{path.inspect}, which is not a valid route: " \
|
|
167
|
+
"a route must be a non-empty string beginning with \"/\"."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def collision_message(path, existing, incoming)
|
|
171
|
+
"Route collision on #{path.inspect}: #{route_label(*existing)} and " \
|
|
172
|
+
"#{route_label(*incoming)} resolve to the same route. Rename one class, " \
|
|
173
|
+
"set an explicit component_path/page_path, or mark one abstract! if it should not route."
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def route_label(klass, kind)
|
|
177
|
+
case kind
|
|
178
|
+
when :stream then "the SSE stream endpoint of component #{klass.name}"
|
|
179
|
+
when :page then "page #{klass.name}"
|
|
180
|
+
else "component #{klass.name}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Match a Sinatra-style pattern against a path.
|
|
185
|
+
# Returns a hash of extracted params, or nil if no match.
|
|
186
|
+
def match_pattern(pattern, path)
|
|
187
|
+
pattern_parts = pattern.split("/")
|
|
188
|
+
path_parts = path.split("/")
|
|
189
|
+
return nil unless pattern_parts.length == path_parts.length
|
|
190
|
+
|
|
191
|
+
params = {}
|
|
192
|
+
pattern_parts.zip(path_parts).each do |pat, val|
|
|
193
|
+
if pat.start_with?(":")
|
|
194
|
+
params[pat[1..].to_sym] = val
|
|
195
|
+
elsif pat != val
|
|
196
|
+
return nil
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
params
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|