weft 0.1.0 → 0.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +134 -28
  3. data/README.md +46 -23
  4. data/docs/app-patterns.md +8 -7
  5. data/docs/arbre.md +49 -18
  6. data/docs/configuration.md +42 -35
  7. data/docs/dsl.md +356 -105
  8. data/docs/error-handling.md +64 -24
  9. data/docs/examples/active-search.md +10 -10
  10. data/docs/examples/browser-dialogs.md +10 -10
  11. data/docs/examples/bulk-update.md +11 -11
  12. data/docs/examples/click-to-edit.md +14 -14
  13. data/docs/examples/click-to-load.md +8 -8
  14. data/docs/examples/delete-row.md +17 -19
  15. data/docs/examples/edit-row.md +17 -14
  16. data/docs/examples/file-upload.md +5 -5
  17. data/docs/examples/infinite-scroll.md +8 -8
  18. data/docs/examples/inline-expansion.md +8 -8
  19. data/docs/examples/inline-validation.md +17 -17
  20. data/docs/examples/lazy-loading.md +8 -8
  21. data/docs/examples/live-ticker.md +1 -1
  22. data/docs/examples/modal-dialog.md +3 -3
  23. data/docs/examples/progress-bar.md +1 -1
  24. data/docs/examples/reset-user-input.md +7 -7
  25. data/docs/examples/tabs.md +4 -4
  26. data/docs/examples/tooltip.md +8 -8
  27. data/docs/examples/updating-other-content.md +9 -9
  28. data/docs/examples/value-select.md +11 -11
  29. data/docs/params.md +112 -0
  30. data/docs/routing.md +13 -13
  31. data/docs/tutorial.md +46 -48
  32. data/lib/weft/action.rb +4 -2
  33. data/lib/weft/autoloading.rb +69 -0
  34. data/lib/weft/component.rb +97 -31
  35. data/lib/weft/configuration.rb +37 -5
  36. data/lib/weft/context/expansion.rb +184 -0
  37. data/lib/weft/context/interception.rb +22 -2
  38. data/lib/weft/context/modifiers.rb +78 -0
  39. data/lib/weft/context/traversal.rb +80 -0
  40. data/lib/weft/context/wiring.rb +85 -0
  41. data/lib/weft/context.rb +70 -164
  42. data/lib/weft/defaults/error_component.rb +57 -21
  43. data/lib/weft/defaults/error_page.rb +12 -10
  44. data/lib/weft/defaults/not_found_component.rb +14 -12
  45. data/lib/weft/defaults/not_found_page.rb +9 -8
  46. data/lib/weft/dsl/actions.rb +9 -9
  47. data/lib/weft/dsl/inclusions.rb +48 -11
  48. data/lib/weft/dsl/params.rb +265 -0
  49. data/lib/weft/dsl/recoveries.rb +36 -6
  50. data/lib/weft/dsl/sandbox.rb +26 -0
  51. data/lib/weft/dsl/triggers.rb +28 -8
  52. data/lib/weft/dsl/updates.rb +31 -8
  53. data/lib/weft/error.rb +12 -1
  54. data/lib/weft/page/assets.rb +222 -0
  55. data/lib/weft/page/head.rb +87 -0
  56. data/lib/weft/page.rb +55 -239
  57. data/lib/weft/params/assembly.rb +170 -0
  58. data/lib/weft/params.rb +138 -0
  59. data/lib/weft/presets.rb +96 -0
  60. data/lib/weft/redirect.rb +7 -7
  61. data/lib/weft/registry/eligibility.rb +5 -19
  62. data/lib/weft/registry.rb +58 -18
  63. data/lib/weft/resolver.rb +48 -20
  64. data/lib/weft/router/actions.rb +106 -22
  65. data/lib/weft/router/errors.rb +223 -83
  66. data/lib/weft/router/oob_includes.rb +202 -17
  67. data/lib/weft/router/streaming.rb +86 -20
  68. data/lib/weft/router.rb +33 -25
  69. data/lib/weft/version.rb +1 -1
  70. data/lib/weft.rb +37 -24
  71. metadata +32 -8
  72. data/lib/weft/attributes.rb +0 -65
  73. data/lib/weft/dsl/attributes.rb +0 -43
  74. data/lib/weft/shorthands.rb +0 -57
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "arbre"
4
+
5
+ require "weft/error"
6
+ require "weft/registry"
7
+
8
+ module Weft
9
+ class Page < Arbre::Component
10
+ # The asset surface of the page head: the register_* family (class-level
11
+ # DSL, accumulate semantics — subclasses add atop their parents) plus the
12
+ # rendering and URL-resolution machinery that emits them. Mixed into
13
+ # Weft::Page alongside Head, whose build_head drives render_assets.
14
+ module Assets
15
+ def self.included(base) = base.extend(ClassMethods)
16
+
17
+ HTMX_SRC = "https://unpkg.com/htmx.org@2.0.4"
18
+ HTMX_ATTRS = {
19
+ integrity: "sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+",
20
+ crossorigin: "anonymous"
21
+ }.freeze
22
+ HTMX_SSE_SRC = "https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"
23
+ HTMX_SSE_ATTRS = {
24
+ integrity: "sha384-fw+eTlCc7suMV/1w/7fr2/PmwElUIt5i82bi+qTiLXvjRXZ2/FkiTNA/w0MhXnGI",
25
+ crossorigin: "anonymous"
26
+ }.freeze
27
+
28
+ # Class-level registration DSL. Each register_* call accumulates; the
29
+ # matching reader returns own + inherited entries in ancestor order.
30
+ module ClassMethods
31
+ # Register a stylesheet to include in the page head.
32
+ #
33
+ # Absolute URLs (http(s)://, protocol-relative //, leading /) render as-is.
34
+ # Bare-relative paths resolve against a static_assets bundle: either the
35
+ # one named via `assets:`, or the :default bundle if one is configured.
36
+ # When neither applies, the path passes through unchanged and the browser
37
+ # resolves it relative to the current page URL.
38
+ #
39
+ # register_stylesheet "https://cdn.example.com/bootstrap.css"
40
+ # register_stylesheet "css/app.css" # resolves against :default
41
+ # register_stylesheet "tailwind/tw.css", assets: :vendor
42
+ def register_stylesheet(href, assets: nil)
43
+ own_stylesheets << { href: href, assets: assets }
44
+ end
45
+
46
+ # Register a script to include in the page head. Same resolution rules
47
+ # as register_stylesheet. Additional kwargs become HTML attributes on
48
+ # the <script> tag.
49
+ #
50
+ # register_script "https://cdn.example.com/app.js", defer: "defer"
51
+ # register_script "js/app.js" # resolves against :default
52
+ # register_script "vendor/x.js", assets: :vendor, defer: "defer"
53
+ def register_script(src, assets: nil, **html_attrs)
54
+ own_scripts << { src: src, attrs: html_attrs, assets: assets }
55
+ end
56
+
57
+ # Register a block of inline CSS to include in the page head.
58
+ # Each registered string emits as its own <style> tag (subclasses
59
+ # add on top of their parent's contributions; nothing replaces).
60
+ #
61
+ # register_inline_css <<~CSS
62
+ # .card { padding: 1rem; }
63
+ # CSS
64
+ def register_inline_css(css)
65
+ own_inline_css << css
66
+ end
67
+
68
+ # Register a block of inline JavaScript to include in the page head.
69
+ # Same accumulate semantics as register_inline_css; emitted after the
70
+ # registered external scripts, so registered libraries are in reach.
71
+ #
72
+ # register_inline_js "htmx.logAll();"
73
+ def register_inline_js(javascript)
74
+ own_inline_js << javascript
75
+ end
76
+
77
+ # All registered stylesheets (own + inherited).
78
+ def stylesheets
79
+ if superclass.respond_to?(:stylesheets)
80
+ superclass.stylesheets + own_stylesheets
81
+ else
82
+ own_stylesheets.dup
83
+ end
84
+ end
85
+
86
+ # All registered scripts (own + inherited).
87
+ def scripts
88
+ if superclass.respond_to?(:scripts)
89
+ superclass.scripts + own_scripts
90
+ else
91
+ own_scripts.dup
92
+ end
93
+ end
94
+
95
+ # All registered inline CSS blocks (own + inherited).
96
+ def inline_css
97
+ if superclass.respond_to?(:inline_css)
98
+ superclass.inline_css + own_inline_css
99
+ else
100
+ own_inline_css.dup
101
+ end
102
+ end
103
+
104
+ # All registered inline JS blocks (own + inherited).
105
+ def inline_js
106
+ if superclass.respond_to?(:inline_js)
107
+ superclass.inline_js + own_inline_js
108
+ else
109
+ own_inline_js.dup
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def own_stylesheets = @own_stylesheets ||= []
116
+ def own_scripts = @own_scripts ||= []
117
+ def own_inline_css = @own_inline_css ||= []
118
+ def own_inline_js = @own_inline_js ||= []
119
+ end
120
+
121
+ private
122
+
123
+ def render_assets
124
+ render_stylesheets
125
+ render_htmx_script if Weft.configuration.include_htmx
126
+ render_sse_script if include_sse_ext?
127
+ render_scripts
128
+ render_inline_js
129
+ render_inline_css
130
+ end
131
+
132
+ def render_stylesheets
133
+ self.class.stylesheets.each do |entry|
134
+ link href: resolve_asset_url(entry[:href], assets: entry[:assets]), rel: "stylesheet"
135
+ end
136
+ end
137
+
138
+ def render_scripts
139
+ self.class.scripts.each do |entry|
140
+ script(src: resolve_asset_url(entry[:src], assets: entry[:assets]), **entry[:attrs])
141
+ end
142
+ end
143
+
144
+ def render_inline_js
145
+ self.class.inline_js.each { |js| script { text_node js.html_safe } }
146
+ end
147
+
148
+ def render_inline_css
149
+ self.class.inline_css.each { |css| style { text_node css.html_safe } }
150
+ end
151
+
152
+ def render_htmx_script
153
+ script src: HTMX_SRC, **HTMX_ATTRS
154
+ end
155
+
156
+ def render_sse_script
157
+ script src: HTMX_SSE_SRC, **HTMX_SSE_ATTRS
158
+ end
159
+
160
+ # Resolve the include_sse_ext configuration into a boolean. :auto defers
161
+ # to the Registry; true/false short-circuit it.
162
+ def include_sse_ext?
163
+ case Weft.configuration.include_sse_ext
164
+ when true then true
165
+ when false then false
166
+ else Weft.registry.any_sse_components?
167
+ end
168
+ end
169
+
170
+ # Resolve a registered asset path to a final URL.
171
+ #
172
+ # - Absolute URLs (http(s)://, //, /) pass through unchanged. Passing an
173
+ # `assets:` kwarg alongside an absolute URL raises — the kwarg is only
174
+ # meaningful for relative paths, and accepting it silently would train
175
+ # callers to attach it everywhere "just in case."
176
+ # - Bare-relative paths with an explicit `assets: :name` resolve against
177
+ # that bundle's root. Unknown bundle names raise with a list of
178
+ # configured names.
179
+ # - Bare-relative paths without an `assets:` kwarg resolve against the
180
+ # :default bundle if one is configured; otherwise they pass through and
181
+ # the browser interprets them relative to the current page URL.
182
+ def resolve_asset_url(path, assets: nil)
183
+ return resolve_absolute_asset_url(path, assets) if absolute_asset_url?(path)
184
+
185
+ resolve_relative_asset_url(path, assets)
186
+ end
187
+
188
+ def resolve_absolute_asset_url(path, assets)
189
+ raise_absolute_with_assets_kwarg!(path, assets) if assets
190
+
191
+ path
192
+ end
193
+
194
+ def resolve_relative_asset_url(path, assets)
195
+ bundles = Weft.configuration.static_assets
196
+ target = assets&.to_sym || (bundles.key?(:default) ? :default : nil)
197
+ return path unless target
198
+
199
+ bundle = bundles[target]
200
+ raise_unknown_assets_bundle!(path, target, bundles) unless bundle
201
+
202
+ "#{bundle[:root]}/#{path}"
203
+ end
204
+
205
+ def absolute_asset_url?(path)
206
+ path.start_with?("http://", "https://", "//", "/")
207
+ end
208
+
209
+ def raise_absolute_with_assets_kwarg!(path, assets)
210
+ raise Weft::InvalidUsage,
211
+ "static asset #{path.inspect}: assets: #{assets.inspect} " \
212
+ "is only meaningful for relative paths (absolute URLs render as-is)"
213
+ end
214
+
215
+ def raise_unknown_assets_bundle!(path, target, bundles)
216
+ raise Weft::InvalidUsage,
217
+ "static asset #{path.inspect} references assets bundle #{target.inspect}, " \
218
+ "but no such bundle is configured. Configured bundles: #{bundles.keys.inspect}"
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "arbre"
4
+
5
+ require "weft/dsl/sandbox"
6
+ require "weft/error"
7
+ require "weft/params"
8
+
9
+ module Weft
10
+ class Page < Arbre::Component
11
+ # The document-head surface: the `title` verb (class-level DSL, override
12
+ # semantics — nearest declaration in the ancestry wins) and the head
13
+ # assembly that consumes it. Mixed into Weft::Page alongside Assets,
14
+ # whose render_assets this module's build_head drives.
15
+ module Head
16
+ def self.included(base) = base.extend(ClassMethods)
17
+
18
+ module ClassMethods
19
+ # Declare the page's <title>. A static value, or a block computed from
20
+ # the page's params at render time — a `(params) → value` function run
21
+ # in the verb sandbox, like every other verb block.
22
+ #
23
+ # title "Orders"
24
+ # title { |params| "Order ##{params.order_id}" }
25
+ #
26
+ # The nearest declaration in the class ancestry wins; without one
27
+ # anywhere, the title falls back to "Weft".
28
+ def title(value = nil, &block)
29
+ if value && block
30
+ raise Weft::InvalidDefinition, "#{name}: title takes a value or a block, not both"
31
+ elsif value.nil? && block.nil?
32
+ raise Weft::InvalidDefinition, "#{name}: title needs a value or a block"
33
+ end
34
+
35
+ @title_declaration = block || value
36
+ end
37
+
38
+ # The declared title (own or nearest inherited); nil when no ancestor declares.
39
+ def title_declaration
40
+ if instance_variable_defined?(:@title_declaration)
41
+ @title_declaration
42
+ elsif superclass.respond_to?(:title_declaration)
43
+ superclass.title_declaration
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def build_head
51
+ insert_tag(Arbre::HTML::Head) do
52
+ meta charset: "utf-8"
53
+ meta name: "viewport", content: "width=device-width, initial-scale=1"
54
+ title { text_node resolved_page_title }
55
+ render_assets
56
+ render_htmx_config
57
+ end
58
+ end
59
+
60
+ # The page's <title> text: the declared title (static, or block-computed
61
+ # from params in the verb sandbox), else "Weft".
62
+ def resolved_page_title
63
+ case (declared = self.class.title_declaration)
64
+ when Proc then Weft::DSL::Sandbox.run(@params || Weft::Params.new({}), &declared)
65
+ when nil then "Weft"
66
+ else declared
67
+ end
68
+ end
69
+
70
+ def render_htmx_config
71
+ script do
72
+ text_node htmx_config_js.html_safe
73
+ end
74
+ end
75
+
76
+ def htmx_config_js
77
+ <<~JS
78
+ htmx.config.responseHandling = [
79
+ {code: "204", swap: false},
80
+ {code: "[23]..", swap: true},
81
+ {code: "[45]..", swap: true, error: true}
82
+ ];
83
+ JS
84
+ end
85
+ end
86
+ end
87
+ end
data/lib/weft/page.rb CHANGED
@@ -1,18 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "arbre"
3
4
  require "uri"
4
5
 
6
+ require "weft/context"
7
+ require "weft/context/interception"
8
+ require "weft/context/traversal"
9
+ require "weft/dsl/containers"
10
+ require "weft/dsl/params"
11
+ require "weft/dsl/recoveries"
12
+ require "weft/error"
13
+ require "weft/page/assets"
14
+ require "weft/page/head"
15
+ require "weft/registry"
16
+ require "weft/registry/eligibility"
17
+
5
18
  module Weft
6
19
  # 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.
20
+ # head, body); the head surface — the `title` verb and the registered
21
+ # scripts and stylesheets — lives in Page::Head and Page::Assets.
22
+ # Subclass to add application-specific assets and CSS.
9
23
  #
10
24
  # Pages auto-route via page_path declarations or class-name inference.
11
25
  # The Router serves them as full documents at the resolved URL patterns.
12
26
  #
13
27
  # class OrderDetailPage < Weft::Page
14
28
  # self.page_path = "/orders/:order_id"
15
- # attribute :order_id
29
+ # param :order_id
16
30
  # end
17
31
  #
18
32
  # Subclasses without an explicit page_path auto-infer one from the class
@@ -20,13 +34,16 @@ module Weft
20
34
  # stripped (DashboardPage and Dashboard both route at "/dashboard"). Use
21
35
  # abstract! to opt out — typical for an intermediate base class that hosts
22
36
  # shared assets and helpers but isn't itself a destination.
23
- class Page < Arbre::Component # rubocop:disable Metrics/ClassLength
37
+ class Page < Arbre::Component
24
38
  extend Weft::Registry::Eligibility
25
39
 
26
40
  include Weft::Context::Interception
27
- include Weft::DSL::Attributes
41
+ include Weft::DSL::Params
28
42
  include Weft::DSL::Recoveries
29
43
  include Weft::DSL::Containers
44
+ include Weft::Context::Traversal
45
+ include Assets
46
+ include Head
30
47
 
31
48
  # @!method weft_page(*args, &block)
32
49
  # @api private
@@ -34,20 +51,9 @@ module Weft
34
51
  # subclass Weft::Page and render via the Router; they do not call this.
35
52
  builder_method :weft_page
36
53
 
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
54
  class << self
49
55
  # Class-level page path pattern. Sinatra-style string with :param segments.
50
- # Bidirectional: forward (interpolate attrs → URL) and reverse (match request → params).
56
+ # Bidirectional: forward (interpolate params → URL) and reverse (match request → params).
51
57
  #
52
58
  # self.page_path = "/orders/:order_id"
53
59
  def page_path
@@ -60,28 +66,28 @@ module Weft
60
66
 
61
67
  attr_writer :page_path
62
68
 
63
- # Resolve the page path by interpolating attrs into the pattern.
69
+ # Resolve the page path by interpolating params into the pattern.
64
70
  # OrderDetailPage.resolve_page_path(order_id: "42") # => "/orders/42"
65
- def resolve_page_path(attrs = {})
71
+ def resolve_page_path(params = {})
66
72
  pattern = page_path || default_page_path
67
- pattern.gsub(/:(\w+)/) { attrs[::Regexp.last_match(1).to_sym] || ":#{::Regexp.last_match(1)}" }
73
+ pattern.gsub(/:(\w+)/) { params[::Regexp.last_match(1).to_sym] || ":#{::Regexp.last_match(1)}" }
68
74
  end
69
75
 
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
76
+ # Build a redirect URL targeting this page with the given params.
77
+ # Path :param segments interpolate from params; declared-but-not-path
78
+ # params become query string entries. Anything not in the page's
73
79
  # declared schema is discarded — never leaks into the URL.
74
80
  #
75
81
  # class OrderDetailPage < Weft::Page
76
82
  # self.page_path = "/orders/:order_id"
77
- # attribute :order_id
78
- # attribute :highlight_section
83
+ # param :order_id
84
+ # param :highlight_section
79
85
  # end
80
86
  # OrderDetailPage.redirect_url(order_id: 42, highlight_section: "items", junk: "x")
81
87
  # # => "/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
88
+ def redirect_url(params = {})
89
+ path = resolve_page_path(params)
90
+ query = params.slice(*(self.params.keys - path_param_keys)).compact
85
91
  query.empty? ? path : "#{path}?#{::URI.encode_www_form(query)}"
86
92
  end
87
93
 
@@ -97,12 +103,12 @@ module Weft
97
103
  # A page is inferred-routable if it has an explicit page_path, or if its
98
104
  # class name yields a usable default — i.e. the demodulized name has a
99
105
  # non-empty stem after stripping any trailing "Page" suffix. The suffix
100
- # is optional: FooBarPage and BazBar both route. Pages with attributes
106
+ # is optional: FooBarPage and BazBar both route. Pages with params
101
107
  # are not inferred-routable; they require an explicit page_path (a
102
108
  # parameterized route can't be derived from the name; see default_page_path).
103
109
  def inferred_routable?
104
110
  return true if instance_variable_defined?(:@page_path)
105
- return false if attributes.any?
111
+ return false if params.any?
106
112
 
107
113
  !name.to_s.delete_suffix("Page").demodulize.empty?
108
114
  end
@@ -113,98 +119,23 @@ module Weft
113
119
  end
114
120
 
115
121
  # 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)
122
+ # The kwargs are pseudo-wire: exactly what a request's query/path params
123
+ # would carry. Used by the Router for full-document responses, and
124
+ # available to users for testing or standalone rendering.
125
+ def render(**wire_params)
119
126
  klass = self
120
- Weft::Context.new({}, nil) do
121
- insert_tag(klass, **attributes)
127
+ Weft::Context.new({}, nil, wire_params: wire_params) do
128
+ insert_tag(klass)
122
129
  end.to_s
123
130
  end
124
131
 
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
132
  private
190
133
 
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
134
  def default_page_path
204
- if attributes.any?
135
+ if params.any?
205
136
  raise Weft::InvalidDefinition,
206
- "#{name} declares attributes but no explicit page_path. " \
207
- "Set self.page_path = \"/your/path/:#{attributes.keys.first}\""
137
+ "#{name} declares params but no explicit page_path. " \
138
+ "Set self.page_path = \"/your/path/:#{params.keys.first}\""
208
139
  end
209
140
 
210
141
  stem = name.to_s.delete_suffix("Page")
@@ -220,11 +151,17 @@ module Weft
220
151
  end
221
152
  end
222
153
 
154
+ # Params resolve at construction (see Weft::Component#initialize) so
155
+ # user build bodies can read them before super — e.g. computing body
156
+ # chrome from a record looked up by param.
157
+ def initialize(*)
158
+ super
159
+ @params = assembled_params if self.class.declared_keys.any?
160
+ end
161
+
223
162
  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))
163
+ warn_declared_chrome_collisions(attributes)
164
+ super
228
165
  build_head
229
166
  @body_el = insert_tag(Arbre::HTML::Body)
230
167
  end
@@ -241,127 +178,6 @@ module Weft
241
178
  "<!DOCTYPE html>\n#{super}"
242
179
  end
243
180
 
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
181
  # Gem-default recovery edges. Symbol form defers resolution until
366
182
  # error-handling time so reassigning the configuration knob propagates.
367
183
  # NotFound declared first so its more-specific match wins over StandardError.