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
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "weft/context/interception"
5
+
6
+ module Weft
7
+ # Base class for all Weft components. Extends Arbre::Component with:
8
+ # - Attribute DSL for declaring wire state
9
+ # - Convention-based DOM ID and partial URL
10
+ # - Auto-registration with the global Registry
11
+ class Component < Arbre::Component
12
+ extend Weft::Registry::Eligibility
13
+
14
+ include Weft::DSL::Attributes
15
+ include Weft::DSL::Recoveries
16
+ include Weft::DSL::Triggers
17
+ include Weft::DSL::Inclusions
18
+ include Weft::DSL::Updates
19
+ include Weft::DSL::Actions
20
+ include Weft::DSL::Containers
21
+
22
+ class << self
23
+ # Class-level path override (string or proc). Inherited by subclasses.
24
+ def component_path
25
+ if instance_variable_defined?(:@component_path)
26
+ @component_path
27
+ elsif superclass.respond_to?(:component_path)
28
+ superclass.component_path
29
+ end
30
+ end
31
+
32
+ attr_writer :component_path
33
+
34
+ # Resolves the actual path string for this component class. An explicit
35
+ # class-level override (string or proc) wins; otherwise the configured
36
+ # default proc derives one from the class name (see default_component_path).
37
+ def resolved_component_path
38
+ case (path = component_path)
39
+ when Proc then path.call(self)
40
+ when String then path
41
+ else default_component_path
42
+ end
43
+ end
44
+
45
+ # Inferred routability from declared state, ignoring any explicit
46
+ # override (see Weft::Registry::Eligibility#routable?). A component is
47
+ # independently addressable when it declares interactive behavior —
48
+ # attributes, actions, refresh triggers, or push config. Pure
49
+ # presentational components (none of those) register but are never served.
50
+ # Subclasses fall back to this when they have no override of their own, so
51
+ # an abstract parent does not disable concrete children.
52
+ def inferred_routable?
53
+ attributes.any? || actions.any? || refresh_triggers.any? || !push_config.nil?
54
+ end
55
+
56
+ def inherited(subclass)
57
+ super
58
+ Weft.registry.register(subclass)
59
+ end
60
+
61
+ # Render this component as an HTML string, outside any Arbre DSL context.
62
+ # Used by the Router for partial responses, and available to users for
63
+ # testing, REPL exploration, or any standalone rendering need.
64
+ #
65
+ # StatCard.render(status: "shipped") # => "<div id=\"...\">...</div>"
66
+ def render(**attributes)
67
+ klass = self
68
+ Weft::Context.new({}, nil) do
69
+ insert_tag(klass, **attributes)
70
+ end.to_s
71
+ end
72
+
73
+ # Compute the would-be DOM ID for an instance of this class given a
74
+ # plain attrs hash, without instantiating. The Router uses this to
75
+ # populate the `:component_id` auto-injected attribute when a recovery
76
+ # target opts in. Single source of truth; the instance method delegates.
77
+ def weft_id_for(attrs = {})
78
+ base = name.underscore.tr("/", "-").tr("_", "-")
79
+ primary_value = attrs.respond_to?(:values) ? attrs.values.first : nil
80
+ primary_value ? "#{base}-#{primary_value}" : base
81
+ end
82
+
83
+ private
84
+
85
+ # Gem-default name-based path derivation, plus the well-formedness guard.
86
+ # Mirrors {Weft::Page.default_page_path}: a routable class whose demodulized
87
+ # name has no usable stem (e.g. a bare +Component+ or +Foo::Component+)
88
+ # can't form a sensible default route, so we raise with remediation
89
+ # guidance rather than emit a degenerate "/_components/". Non-routable
90
+ # classes never reach a route and are exempt.
91
+ def default_component_path
92
+ if routable? && name.to_s.demodulize.delete_suffix("Component").empty?
93
+ raise Weft::InvalidDefinition,
94
+ "#{name.inspect} has no resolvable default component path. " \
95
+ "Either rename the class with a meaningful stem (e.g. OrdersPanel), " \
96
+ "set self.component_path = \"/your/path\" explicitly, " \
97
+ "or mark the class abstract! if it isn't meant to route."
98
+ end
99
+
100
+ Weft.configuration.component_path.call(self)
101
+ end
102
+ end
103
+
104
+ include Weft::Context::Interception
105
+
106
+ # Gem-default recovery edges. Symbol form defers resolution until error-handling
107
+ # time so reassigning the matching Weft.configuration knob propagates everywhere.
108
+ # NotFound is declared first (more specific) so a component-context 404 renders
109
+ # the not-found body; StandardError stays last as the catch-all.
110
+ recovers from: Weft::NotFound, with: :not_found_component
111
+ recovers from: StandardError, with: :error_component
112
+
113
+ def build(attributes = {})
114
+ schema = self.class.attributes
115
+ @attrs = Weft::Attributes.extract_from(attributes, using: schema)
116
+ super(attributes.except(*schema.keys))
117
+ self.id = weft_id
118
+ apply_refresh_attrs
119
+ apply_push_attrs
120
+ end
121
+
122
+ # URL to this component's Weft route with current attrs as query params.
123
+ # Pass overrides to change specific attr values in the URL.
124
+ #
125
+ # weft_url # => "/_components/orders_panel?status=shipped&page=1"
126
+ # weft_url(page: 2) # => "/_components/orders_panel?status=shipped&page=2"
127
+ # weft_url(status: nil, page: 1) # => "/_components/orders_panel?page=1"
128
+ def weft_url(**overrides)
129
+ path = self.class.resolved_component_path
130
+ params = @attrs.to_h.merge(overrides).compact
131
+ params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
132
+ end
133
+
134
+ # Convention-based DOM ID: dasherized class name + primary attribute value.
135
+ def weft_id
136
+ self.class.weft_id_for(@attrs ? @attrs.to_h : {})
137
+ end
138
+
139
+ private
140
+
141
+ # URL to this component's Weft route with current attrs (no overrides).
142
+ # Used internally by apply_refresh_attrs.
143
+ def refresh_url
144
+ weft_url
145
+ end
146
+
147
+ def apply_refresh_attrs
148
+ triggers = self.class.refresh_triggers
149
+ return if triggers.empty?
150
+
151
+ set_attribute "hx-get", refresh_url
152
+ set_attribute "hx-trigger", triggers.join(", ")
153
+ set_attribute "hx-swap", "outerHTML"
154
+ end
155
+
156
+ # Apply SSE connection attributes for components declaring `pushes`.
157
+ # Uses innerHTML swap — the wrapper element (holding the SSE connection)
158
+ # must persist across pushes.
159
+ def apply_push_attrs
160
+ config = self.class.push_config
161
+ return unless config&.key?(:every)
162
+
163
+ set_attribute "hx-ext", "sse"
164
+ set_attribute "sse-connect", stream_url
165
+ set_attribute "sse-swap", weft_id
166
+ set_attribute "hx-swap", "innerHTML"
167
+ end
168
+
169
+ # URL to this component's SSE stream endpoint with current attrs.
170
+ def stream_url
171
+ path = "#{self.class.resolved_component_path}/#{Weft.configuration.stream_suffix}"
172
+ params = @attrs.to_h.compact
173
+ params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Weft
6
+ class Configuration
7
+ DEFAULT_COMPONENT_PATH = ->(klass) { "/_components/#{klass.name.to_s.delete_suffix('Component').underscore}" }
8
+ VALID_HTMX_ERRORS = %i[fragment redirect].freeze
9
+ VALID_INCLUDE_SSE_EXT = [:auto, true, false].freeze
10
+ LOG_LEVELS = {
11
+ debug: Logger::DEBUG, info: Logger::INFO, warn: Logger::WARN,
12
+ error: Logger::ERROR, fatal: Logger::FATAL, unknown: Logger::UNKNOWN
13
+ }.freeze
14
+ private_constant :DEFAULT_COMPONENT_PATH, :VALID_HTMX_ERRORS, :VALID_INCLUDE_SSE_EXT, :LOG_LEVELS
15
+
16
+ attr_reader :component_path, :htmx_errors, :include_sse_ext, :log_level, :stream_suffix
17
+ attr_accessor :include_htmx, :auto_reload, :reload_paths, :verbose_error_pages, :router_logging
18
+ attr_writer :error_component, :error_page, :not_found_page, :not_found_component
19
+
20
+ # @api private
21
+ # Instantiated internally by {Weft.configuration}. Configure Weft via
22
+ # +Weft.configure { |c| ... }+ rather than constructing this directly.
23
+ def initialize
24
+ @component_path = DEFAULT_COMPONENT_PATH
25
+ @include_htmx = true
26
+ @include_sse_ext = :auto
27
+ @auto_reload = false
28
+ @reload_paths = []
29
+ @router_logging = false
30
+ @verbose_error_pages = true
31
+ @htmx_errors = :fragment
32
+ @log_level = :info
33
+ @stream_suffix = "_stream"
34
+ @static_assets = {}
35
+ end
36
+
37
+ def component_path=(value)
38
+ raise ArgumentError, "component_path must be a Proc, got #{value.class}" unless value.is_a?(Proc)
39
+
40
+ @component_path = value
41
+ end
42
+
43
+ def htmx_errors=(value)
44
+ unless VALID_HTMX_ERRORS.include?(value)
45
+ raise ArgumentError, "htmx_errors must be :fragment or :redirect, got #{value.inspect}"
46
+ end
47
+
48
+ @htmx_errors = value
49
+ end
50
+
51
+ # Controls when Weft::Page emits the htmx-ext-sse script tag.
52
+ # - :auto (default) -- include only if any registered component declares
53
+ # `pushes`. Driven by Weft.registry.any_sse_components?.
54
+ # - true -- always include (escape hatch for lazy autoload or
55
+ # custom Registry population timing where :auto would miss components).
56
+ # - false -- never include.
57
+ def include_sse_ext=(value)
58
+ unless VALID_INCLUDE_SSE_EXT.include?(value)
59
+ raise ArgumentError, "include_sse_ext must be :auto, true, or false, got #{value.inspect}"
60
+ end
61
+
62
+ @include_sse_ext = value
63
+ end
64
+
65
+ def log_level=(value)
66
+ unless LOG_LEVELS.key?(value)
67
+ raise ArgumentError, "log_level must be one of #{LOG_LEVELS.keys.inspect}, got #{value.inspect}"
68
+ end
69
+
70
+ @log_level = value
71
+ end
72
+
73
+ # The path segment that marks a component's SSE stream endpoint. The leading
74
+ # slash is supplied by Weft, so set the bare segment (e.g. "stream", "sse").
75
+ # It's appended as "<component_path>/<stream_suffix>" on both sides: the
76
+ # client-facing sse-connect URL (Weft::Component#stream_url) and the Router's
77
+ # stream-request routing. Must be a non-empty segment with no slashes.
78
+ def stream_suffix=(value)
79
+ unless value.is_a?(String) && !value.empty? && !value.include?("/")
80
+ raise ArgumentError,
81
+ "stream_suffix must be a non-empty path segment with no slashes — " \
82
+ "Weft adds the leading slash itself (e.g. \"stream\"), got #{value.inspect}"
83
+ end
84
+
85
+ @stream_suffix = value
86
+ end
87
+
88
+ # Resolves the configured log_level symbol to its Logger severity constant
89
+ # (e.g. :warn -> Logger::WARN). Used by Weft.configure to set logger.level.
90
+ def resolved_log_level
91
+ LOG_LEVELS.fetch(@log_level)
92
+ end
93
+
94
+ # Register a directory to serve as static assets at a URL prefix, under
95
+ # a logical bundle name. The bundle name is the stable reference used at
96
+ # call sites (`register_stylesheet "css/app.css", assets: :app`); the
97
+ # root/from pair can be reconfigured (e.g. from env vars in production)
98
+ # without touching call sites.
99
+ #
100
+ # Multi-call: each call adds a {name => {root:, from:}} entry. Raises
101
+ # ArgumentError on duplicate name or duplicate root.
102
+ #
103
+ # c.static_assets root: "/static", from: File.join(APP_ROOT, "public")
104
+ # # name: defaults to :default — call sites without an `assets:` kwarg
105
+ # # implicitly resolve against this bundle.
106
+ #
107
+ # c.static_assets name: :vendor, root: "/vendor",
108
+ # from: File.join(APP_ROOT, "vendor", "assets")
109
+ #
110
+ # Called with no arguments, returns a copy of the registered bundles as a
111
+ # hash (name => {root:, from:}). Used by the apply step and the page-side
112
+ # resolve_asset_url logic.
113
+ def static_assets(name: nil, root: nil, from: nil)
114
+ return @static_assets.transform_values(&:dup) if name.nil? && root.nil? && from.nil?
115
+
116
+ register_static_assets_bundle(name: name, root: root, from: from)
117
+ end
118
+
119
+ # Lazy defaults — resolved on first read so Weft::Defaults classes load before
120
+ # the Configuration class is required. Assignment sticks.
121
+ def error_component
122
+ @error_component ||= Weft::Defaults::ErrorComponent
123
+ end
124
+
125
+ def error_page
126
+ @error_page ||= Weft::Defaults::ErrorPage
127
+ end
128
+
129
+ def not_found_page
130
+ @not_found_page ||= Weft::Defaults::NotFoundPage
131
+ end
132
+
133
+ def not_found_component
134
+ @not_found_component ||= Weft::Defaults::NotFoundComponent
135
+ end
136
+
137
+ private
138
+
139
+ def register_static_assets_bundle(name:, root:, from:)
140
+ if root.nil? || from.nil?
141
+ raise ArgumentError,
142
+ "static_assets requires both root: and from: " \
143
+ "(e.g., static_assets root: '/static', from: File.join(APP_ROOT, 'public'))"
144
+ end
145
+ unless root.start_with?("/")
146
+ raise Weft::InvalidConfiguration, "static_assets root must start with '/', got #{root.inspect}"
147
+ end
148
+
149
+ bundle_name = (name || :default).to_sym
150
+ normalized_root = normalize_static_assets_root(root)
151
+ check_static_assets_uniqueness!(bundle_name, normalized_root)
152
+
153
+ @static_assets[bundle_name] = { root: normalized_root, from: from }
154
+ end
155
+
156
+ # Strip a trailing slash so URL building can always `"#{root}/#{path}"`
157
+ # without producing double slashes. "/" is left alone to allow root-mount
158
+ # (though apps typically use a prefix).
159
+ def normalize_static_assets_root(root)
160
+ root == "/" ? root : root.chomp("/")
161
+ end
162
+
163
+ def check_static_assets_uniqueness!(name, root)
164
+ if (existing = @static_assets[name])
165
+ raise Weft::InvalidConfiguration,
166
+ "static_assets bundle #{name.inspect} is already registered " \
167
+ "(at root #{existing[:root].inspect}, from #{existing[:from].inspect}); refusing to overwrite"
168
+ end
169
+
170
+ conflict = @static_assets.find { |_n, b| b[:root] == root }
171
+ return unless conflict
172
+
173
+ raise Weft::InvalidConfiguration,
174
+ "static_assets root #{root.inspect} is already registered (by bundle #{conflict[0].inspect})"
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ class Context < Arbre::Context
5
+ # Mixin for Arbre elements that need to forward Weft kwargs
6
+ # (action:, navigate:, trigger:, etc.) to the Context for expansion.
7
+ #
8
+ # Included by Weft::Context, Weft::Component, and Weft::Page — all three
9
+ # share this one #insert_tag. It forwards any Weft kwargs to the root
10
+ # Weft::Context (via +arbre_context+), which is the expansion engine
11
+ # (#weft_kwarg? / #expand_weft_attrs).
12
+ module Interception
13
+ def insert_tag(klass, *args, &)
14
+ h = args.last
15
+ if h.is_a?(Hash) && arbre_context.is_a?(Weft::Context) && arbre_context.weft_kwarg?(h)
16
+ args[-1] = arbre_context.expand_weft_attrs(h, for_class: klass)
17
+ end
18
+ super
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "weft/context/interception"
4
+
5
+ module Weft
6
+ # Arbre::Context subclass that intercepts element creation to expand
7
+ # Weft kwargs into htmx attributes.
8
+ #
9
+ # Works at every nesting depth because Arbre instance_evals the top-level
10
+ # block, making the Context the receiver for all insert_tag calls throughout
11
+ # the element tree.
12
+ #
13
+ # Supported kwargs:
14
+ # - `action: :name` — expands into htmx attrs for a declared performs/transfers action
15
+ # - `navigate: { key: val }` — expands into htmx GET to self with overridden attrs
16
+ # - `trigger: "event"` — sets hx-trigger (standalone or alongside action:/navigate:)
17
+ class Context < Arbre::Context
18
+ include Interception
19
+
20
+ # @api private
21
+ # Expands Weft kwargs into htmx attributes. Invoked by the Interception
22
+ # mixin's #insert_tag on the root Weft::Context (via +arbre_context+).
23
+ def expand_weft_attrs(attrs, for_class: nil)
24
+ attrs = attrs.dup
25
+ custom_trigger = attrs.delete(:trigger)
26
+ push_url = attrs.delete(:push_url)
27
+ attrs = expand_action(attrs, for_class: for_class) || expand_navigate(attrs) || expand_loads(attrs) ||
28
+ expand_shorthand(attrs) || attrs
29
+ attrs["hx-trigger"] = resolve_trigger(custom_trigger) if custom_trigger
30
+ attrs["hx-push-url"] = push_url.to_s if push_url
31
+ attrs
32
+ end
33
+
34
+ # @api private
35
+ # Guard check for whether an attrs hash carries any Weft kwarg. Invoked by
36
+ # the Interception mixin's #insert_tag on the root Weft::Context (via
37
+ # +arbre_context+).
38
+ def weft_kwarg?(hash)
39
+ hash[:action].is_a?(Symbol) || hash.key?(:trigger) || hash[:navigate].is_a?(Hash) ||
40
+ hash[:loads].is_a?(Class) || hash.key?(:push_url) || find_shorthand_kwarg(hash)
41
+ end
42
+
43
+ private
44
+
45
+ def expand_action(attrs, for_class: nil)
46
+ action_name = attrs[:action]
47
+ return unless action_name.is_a?(Symbol)
48
+
49
+ component = find_action_context(action_name)
50
+ return unless component
51
+
52
+ action = component.class.action_for(action_name)
53
+ htmx = action.to_htmx_attrs(component)
54
+ expanded = attrs.except(:action).merge(htmx)
55
+ return augment_for_form(expanded, action, htmx) if for_class && for_class <= Arbre::HTML::Form
56
+
57
+ expanded
58
+ end
59
+
60
+ # On <form> elements, also emit the HTML action and method attributes so
61
+ # non-JS submission works (browser POSTs to the same URL htmx would).
62
+ # Drop hx-vals because the form fields are the submission payload —
63
+ # hx-vals would duplicate or shadow them.
64
+ def augment_for_form(expanded, action, htmx)
65
+ url = htmx["hx-#{action.method}"]
66
+ expanded.except("hx-vals").merge("action" => url, "method" => action.method.to_s)
67
+ end
68
+
69
+ def expand_navigate(attrs)
70
+ overrides = attrs[:navigate]
71
+ return unless overrides.is_a?(Hash)
72
+
73
+ component = find_nearest_component
74
+ return unless component
75
+
76
+ attrs.except(:navigate).merge(navigate_attrs(component, overrides))
77
+ end
78
+
79
+ def expand_loads(attrs)
80
+ target_class = attrs[:loads]
81
+ return unless target_class.is_a?(Class)
82
+
83
+ validate_loads_kwargs!(attrs)
84
+ remaining = attrs.except(:loads, :swap, :target, :with)
85
+ remaining.merge(loads_attrs(target_class, resolve_with(attrs), attrs[:swap], attrs[:target]))
86
+ end
87
+
88
+ def expand_shorthand(attrs)
89
+ shorthand_key, target_class = find_shorthand_kwarg(attrs)
90
+ return unless shorthand_key
91
+
92
+ build_shorthand_attrs(attrs, shorthand_key, target_class, Weft.shorthand(shorthand_key))
93
+ end
94
+
95
+ # A shorthand value is either a target Class (derive the URL from it) or a
96
+ # ready-made URL String (retry-style — the caller already has the URL).
97
+ def find_shorthand_kwarg(attrs)
98
+ attrs.find { |k, v| (v.is_a?(Class) || v.is_a?(String)) && Weft.shorthand(k) }
99
+ end
100
+
101
+ def build_shorthand_attrs(attrs, shorthand_key, target_or_url, preset)
102
+ target = attrs[:target] || preset[:target]
103
+ raise ArgumentError, "#{shorthand_key}: requires target: (e.g., target: :self)" unless target
104
+
105
+ swap = attrs[:swap] || preset[:swap]
106
+ htmx = if target_or_url.is_a?(String)
107
+ htmx_get_attrs(target_or_url, swap, target)
108
+ else
109
+ loads_attrs(target_or_url, resolve_with(attrs), swap, target)
110
+ end
111
+ htmx["hx-trigger"] = resolve_trigger(preset[:trigger]) if preset[:trigger]
112
+ attrs.except(shorthand_key, :swap, :target, :with).merge(htmx)
113
+ end
114
+
115
+ def resolve_with(attrs)
116
+ attrs[:with] || find_nearest_component&.attrs&.to_h || {}
117
+ end
118
+
119
+ def validate_loads_kwargs!(attrs)
120
+ raise ArgumentError, "loads: requires swap: (e.g., swap: :fill)" unless attrs[:swap]
121
+ raise ArgumentError, "loads: requires target: (e.g., target: :self)" unless attrs[:target]
122
+ end
123
+
124
+ def find_action_context(action_name)
125
+ el = current_arbre_element
126
+ while el
127
+ return el if el.is_a?(Weft::Component) && el.class.action_for(action_name)
128
+
129
+ el = el.parent
130
+ end
131
+ nil
132
+ end
133
+
134
+ def find_nearest_component
135
+ el = current_arbre_element
136
+ while el
137
+ return el if el.is_a?(Weft::Component)
138
+
139
+ el = el.parent
140
+ end
141
+ nil
142
+ end
143
+
144
+ def navigate_attrs(component, overrides)
145
+ {
146
+ "hx-get" => component.weft_url(**overrides),
147
+ "hx-target" => "##{component.weft_id}",
148
+ "hx-swap" => "outerHTML"
149
+ }
150
+ end
151
+
152
+ def loads_attrs(target_class, with_attrs, swap, target)
153
+ htmx_get_attrs(component_url(target_class, with_attrs), swap, target)
154
+ end
155
+
156
+ def component_url(target_class, with_attrs)
157
+ path = target_class.resolved_component_path
158
+ params = with_attrs.compact
159
+ params.empty? ? path : "#{path}?#{URI.encode_www_form(params)}"
160
+ end
161
+
162
+ def htmx_get_attrs(url, swap, target)
163
+ {
164
+ "hx-get" => url,
165
+ "hx-swap" => Action.resolve_swap(swap),
166
+ "hx-target" => resolve_target(target)
167
+ }
168
+ end
169
+
170
+ def resolve_target(target)
171
+ case target
172
+ when :self then "this"
173
+ when String then target
174
+ else
175
+ # Arbre element reference — extract #id
176
+ target.respond_to?(:id) ? "##{target.id}" : target.to_s
177
+ end
178
+ end
179
+
180
+ def resolve_trigger(value)
181
+ Action.resolve_trigger(value)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module Defaults
5
+ # Gem-default component rendered when an action or partial-render fails
6
+ # and no user-declared recovers entry matches. Verbose mode shows the
7
+ # exception class and message; non-verbose shows a generic message.
8
+ # Users override by setting Weft.configuration.error_component or by
9
+ # declaring their own `recovers` chain.
10
+ class ErrorComponent < Weft::Component
11
+ abstract!
12
+
13
+ # Auto-injected attributes (opt-in, schema-gated by the Router). The
14
+ # Router populates these at error-handling time on any recovers target
15
+ # that declares them.
16
+ # :component_id preserves the failing component's DOM identity so the
17
+ # recovered fragment lands at the original element's id — preventing
18
+ # duplicate IDs when several siblings fail in the same window.
19
+ # :retry_url is the failing component's GET URL with current attrs.
20
+ attribute :component_id
21
+ attribute :exception
22
+ attribute :request_path
23
+ attribute :status_code
24
+ attribute :retry_url
25
+
26
+ STYLE = "padding:1rem; border:1px solid #fca5a5; border-radius:6px; " \
27
+ "background:#fef2f2; color:#991b1b; font-size:0.875rem"
28
+ MONO_STYLE = "margin-bottom:0.5rem; font-family:monospace; font-size:0.8rem"
29
+ BUTTON_STYLE = "padding:0.25rem 0.75rem; border:1px solid #b91c1c; border-radius:4px; " \
30
+ "background:#fff; color:#b91c1c; font-size:0.75rem; cursor:pointer"
31
+
32
+ def build(attributes = {})
33
+ super
34
+ add_class "weft-error"
35
+ set_attribute "style", STYLE
36
+
37
+ div(style: "font-weight:600; margin-bottom:0.5rem") { text_node "Something went wrong" }
38
+ render_verbose if Weft.configuration.verbose_error_pages && @attrs.exception
39
+ render_retry_button if @attrs.retry_url
40
+ end
41
+
42
+ # Preserve the failing component's DOM identity when the Router injected
43
+ # :component_id. Otherwise fall back to the class-derived default.
44
+ def weft_id
45
+ @attrs.component_id || super
46
+ end
47
+
48
+ private
49
+
50
+ def render_verbose
51
+ exc = @attrs.exception
52
+ div(style: MONO_STYLE) { text_node "#{exc.class}: #{exc.message}" }
53
+ end
54
+
55
+ # Retry by re-issuing a GET to refresh the failing component. The :retry
56
+ # shorthand supplies the htmx wiring (outerHTML-swap the closest
57
+ # .weft-error box), so it works whether or not :component_id was carved out.
58
+ def render_retry_button
59
+ button "Retry", retry: @attrs.retry_url, style: BUTTON_STYLE
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module Defaults
5
+ # Gem-default full-document page rendered for traditional (non-htmx)
6
+ # responses when an error falls through to the Page recovers chain
7
+ # and no user override matches. Thin wrapper around ErrorComponent.
8
+ class ErrorPage < Weft::Page
9
+ self.page_path = "/_weft/error"
10
+
11
+ attribute :exception
12
+ attribute :request_path
13
+ attribute :status_code
14
+
15
+ def build(attributes = {})
16
+ attributes[:title] ||= "Error"
17
+ super
18
+ insert_tag(
19
+ Weft::Defaults::ErrorComponent,
20
+ exception: @attrs.exception,
21
+ request_path: @attrs.request_path,
22
+ status_code: @attrs.status_code
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module Defaults
5
+ # Gem-default component rendered when a routing miss falls through to
6
+ # the Weft::Page chain (C4) or when an action raises Weft::NotFound and
7
+ # no user recovers entry matches.
8
+ class NotFoundComponent < Weft::Component
9
+ abstract!
10
+
11
+ # Opt into the :component_id auto-injected attribute for parity with
12
+ # ErrorComponent — preserves DOM identity when a component-context
13
+ # NotFound recovers through this.
14
+ attribute :component_id
15
+ attribute :request_path
16
+ attribute :status_code
17
+
18
+ STYLE = "padding:1rem; border:1px solid #cbd5e1; border-radius:6px; " \
19
+ "background:#f8fafc; color:#0f172a; font-size:0.875rem"
20
+ MONO_STYLE = "margin-top:0.5rem; font-family:monospace; font-size:0.8rem; color:#475569"
21
+
22
+ def build(attributes = {})
23
+ super
24
+ add_class "weft-not-found"
25
+ set_attribute "style", STYLE
26
+
27
+ div(style: "font-weight:600") { text_node "Not found" }
28
+ render_verbose if Weft.configuration.verbose_error_pages && @attrs.request_path
29
+ end
30
+
31
+ # Preserve the failing component's DOM identity when the Router injected
32
+ # :component_id. Otherwise fall back to the class-derived default.
33
+ def weft_id
34
+ @attrs.component_id || super
35
+ end
36
+
37
+ private
38
+
39
+ def render_verbose
40
+ div(style: MONO_STYLE) { text_node @attrs.request_path.to_s }
41
+ end
42
+ end
43
+ end
44
+ end