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
|
@@ -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
|
data/lib/weft/context.rb
ADDED
|
@@ -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
|