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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +150 -0
  5. data/docs/app-patterns.md +268 -0
  6. data/docs/arbre.md +298 -0
  7. data/docs/configuration.md +219 -0
  8. data/docs/dsl.md +329 -0
  9. data/docs/error-handling.md +136 -0
  10. data/docs/examples/README.md +70 -0
  11. data/docs/examples/active-search.md +125 -0
  12. data/docs/examples/browser-dialogs.md +71 -0
  13. data/docs/examples/bulk-update.md +122 -0
  14. data/docs/examples/click-to-edit.md +113 -0
  15. data/docs/examples/click-to-load.md +77 -0
  16. data/docs/examples/delete-row.md +101 -0
  17. data/docs/examples/edit-row.md +146 -0
  18. data/docs/examples/file-upload.md +96 -0
  19. data/docs/examples/infinite-scroll.md +98 -0
  20. data/docs/examples/inline-expansion.md +98 -0
  21. data/docs/examples/inline-validation.md +101 -0
  22. data/docs/examples/keyboard-shortcuts.md +71 -0
  23. data/docs/examples/lazy-loading.md +96 -0
  24. data/docs/examples/live-ticker.md +91 -0
  25. data/docs/examples/modal-dialog.md +88 -0
  26. data/docs/examples/progress-bar.md +138 -0
  27. data/docs/examples/reset-user-input.md +101 -0
  28. data/docs/examples/tabs.md +102 -0
  29. data/docs/examples/tooltip.md +88 -0
  30. data/docs/examples/updating-other-content.md +169 -0
  31. data/docs/examples/value-select.md +109 -0
  32. data/docs/routing.md +131 -0
  33. data/docs/tutorial.md +372 -0
  34. data/lib/weft/action.rb +83 -0
  35. data/lib/weft/attributes.rb +65 -0
  36. data/lib/weft/component.rb +176 -0
  37. data/lib/weft/configuration.rb +177 -0
  38. data/lib/weft/context/interception.rb +22 -0
  39. data/lib/weft/context.rb +184 -0
  40. data/lib/weft/defaults/error_component.rb +63 -0
  41. data/lib/weft/defaults/error_page.rb +27 -0
  42. data/lib/weft/defaults/not_found_component.rb +44 -0
  43. data/lib/weft/defaults/not_found_page.rb +25 -0
  44. data/lib/weft/defaults.rb +9 -0
  45. data/lib/weft/dsl/actions.rb +72 -0
  46. data/lib/weft/dsl/attributes.rb +43 -0
  47. data/lib/weft/dsl/containers.rb +77 -0
  48. data/lib/weft/dsl/inclusions.rb +49 -0
  49. data/lib/weft/dsl/recoveries.rb +89 -0
  50. data/lib/weft/dsl/triggers.rb +40 -0
  51. data/lib/weft/dsl/updates.rb +86 -0
  52. data/lib/weft/error.rb +64 -0
  53. data/lib/weft/page.rb +371 -0
  54. data/lib/weft/redirect.rb +45 -0
  55. data/lib/weft/registry/eligibility.rb +58 -0
  56. data/lib/weft/registry.rb +202 -0
  57. data/lib/weft/resolver.rb +33 -0
  58. data/lib/weft/router/actions.rb +77 -0
  59. data/lib/weft/router/errors.rb +246 -0
  60. data/lib/weft/router/oob_includes.rb +34 -0
  61. data/lib/weft/router/streaming.rb +63 -0
  62. data/lib/weft/router.rb +191 -0
  63. data/lib/weft/shorthands.rb +57 -0
  64. data/lib/weft/version.rb +5 -0
  65. data/lib/weft.rb +124 -0
  66. 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