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,25 @@
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 a NotFound error falls through to the Page recovers
7
+ # chain. Thin wrapper around NotFoundComponent.
8
+ class NotFoundPage < Weft::Page
9
+ self.page_path = "/_weft/not_found"
10
+
11
+ attribute :request_path
12
+ attribute :status_code
13
+
14
+ def build(attributes = {})
15
+ attributes[:title] ||= "Not found"
16
+ super
17
+ insert_tag(
18
+ Weft::Defaults::NotFoundComponent,
19
+ request_path: @attrs.request_path,
20
+ status_code: @attrs.status_code
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Weft::Defaults namespace — gem-provided Page/Component classes used as
4
+ # fallback targets when no user-declared recovers entry matches. Pointed at
5
+ # by Weft::Configuration#error_component / #error_page / #not_found_page.
6
+ require "weft/defaults/error_component"
7
+ require "weft/defaults/error_page"
8
+ require "weft/defaults/not_found_component"
9
+ require "weft/defaults/not_found_page"
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module DSL
5
+ # Mixin for classes that declare user-initiated actions: `performs`,
6
+ # `transfers`, and the `dismisses` sugar. Included into Weft::Component.
7
+ module Actions
8
+ def self.included(base)
9
+ base.extend(ClassMethods)
10
+ end
11
+
12
+ module ClassMethods
13
+ # Declare a user-initiated action on this component.
14
+ #
15
+ # performs :advance do |attrs|
16
+ # order = Order.find(attrs.order_id)
17
+ # Oms::PrepareOrder.call(order)
18
+ # end
19
+ #
20
+ # performs method: :delete, swap: :delete do |attrs| ... end
21
+ def performs(name = nil, method: :post, swap: :outer_html, target: nil, &block)
22
+ action = Weft::Action.new(name: name, method: method, swap: swap,
23
+ target: target, renders: self, callable: block)
24
+ own_actions[[name, method]] = action
25
+ end
26
+
27
+ # Sugar for performs with swap: :delete. Removes the component from
28
+ # the DOM on success. The callable (if given) runs for side effects;
29
+ # the return value is rendered but htmx ignores the response body.
30
+ #
31
+ # dismisses :close # no side effects
32
+ # dismisses :remove do |attrs| # with side effects
33
+ # Item.find(attrs.item_id).archive!
34
+ # end
35
+ def dismisses(name = nil, method: :delete, &)
36
+ performs(name, method: method, swap: :delete, &)
37
+ end
38
+
39
+ # Declare a transfer — an action that renders a different component.
40
+ #
41
+ # transfers :edit, to: EditableOrderHeader do |attrs|
42
+ # { mode: "full" }
43
+ # end
44
+ def transfers(name = nil, to:, method: :post, swap: :outer_html, target: nil, &block)
45
+ action = Weft::Action.new(name: name, method: method, swap: swap,
46
+ target: target, renders: to, callable: block)
47
+ own_actions[[name, method]] = action
48
+ end
49
+
50
+ # All declared actions (own + inherited), keyed by [name, method].
51
+ def actions
52
+ if superclass.respond_to?(:actions)
53
+ superclass.actions.merge(own_actions)
54
+ else
55
+ own_actions.dup
56
+ end
57
+ end
58
+
59
+ # Look up an action by name (for Context interception).
60
+ def action_for(name)
61
+ actions.values.find { |a| a.name == name }
62
+ end
63
+
64
+ private
65
+
66
+ def own_actions
67
+ @own_actions ||= {}
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module DSL
5
+ # Mixin for classes that declare wire-state attributes.
6
+ # Provides the `attribute` class DSL and `attrs` instance reader.
7
+ # Used by Component (for partial route params) and Page (for page route params).
8
+ module Attributes
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ # Declare a wire attribute. Accepts optional default: and type: kwargs.
15
+ # attribute :status, default: "active", type: :string
16
+ def attribute(name, default: nil, **options)
17
+ meta = { default: default }
18
+ meta[:type] = options[:type] if options.key?(:type)
19
+ own_attributes[name] = meta
20
+ end
21
+
22
+ # Returns all declared attributes (own + inherited), preserving declaration order.
23
+ def attributes
24
+ if superclass.respond_to?(:attributes)
25
+ superclass.attributes.merge(own_attributes)
26
+ else
27
+ own_attributes.dup
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def own_attributes
34
+ @own_attributes ||= {}
35
+ end
36
+ end
37
+
38
+ # Instance access to resolved wire attribute values.
39
+ # Returns a Weft::Attributes object with method-style and hash access.
40
+ attr_reader :attrs
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module DSL
5
+ # Mixin for the `adds_children_to :@ivar` macro — generates the standard
6
+ # Arbre container override that redirects user-block children into a
7
+ # nested element while letting structural children (added during `build`)
8
+ # pass through to the wrapper.
9
+ #
10
+ # Mixed into both Weft::Component and Weft::Page by default. Usable on
11
+ # any class that inherits from Arbre::Component.
12
+ #
13
+ # class Card < Weft::Component
14
+ # adds_children_to :@body
15
+ #
16
+ # def build(attributes = {})
17
+ # super
18
+ # h2 "Header" # structural — goes to wrapper
19
+ # @body = div(class: "card-body")
20
+ # end
21
+ # end
22
+ #
23
+ # The `:@body` form (with the leading `@`) is required — a pedagogical
24
+ # cue that the macro depends on the user assigning the ivar somewhere
25
+ # in `build`. If `build` returns without setting the ivar and a child
26
+ # is then added, the macro raises with a pointed error message.
27
+ module Containers
28
+ def self.included(base)
29
+ base.extend(ClassMethods)
30
+ end
31
+
32
+ module ClassMethods
33
+ # Declare that block-style children should be added to the named
34
+ # instance variable instead of the wrapper element itself.
35
+ def adds_children_to(ivar)
36
+ unless ivar.is_a?(Symbol)
37
+ raise ArgumentError,
38
+ "adds_children_to expects a Symbol (e.g., :@body), got #{ivar.inspect}"
39
+ end
40
+ unless ivar.to_s.start_with?("@")
41
+ raise Weft::InvalidDefinition,
42
+ "adds_children_to expects a Symbol that must start with @ (e.g., :@body), got #{ivar.inspect}"
43
+ end
44
+
45
+ prepend(Containers.behavior_for(ivar))
46
+ end
47
+ end
48
+
49
+ # Build the prepended Module that implements both the `build` wrap
50
+ # (to detect "build returned without assigning the ivar") and the
51
+ # `add_child` redirect. Each call returns a fresh anonymous module
52
+ # — declaring the macro on multiple classes is safe.
53
+ def self.behavior_for(ivar)
54
+ Module.new do
55
+ define_method(:build) do |*args, **kwargs|
56
+ super(*args, **kwargs)
57
+ @_weft_container_built = true
58
+ end
59
+
60
+ define_method(:add_child) do |child|
61
+ target = instance_variable_defined?(ivar) ? instance_variable_get(ivar) : nil
62
+ if target
63
+ target << child
64
+ elsif @_weft_container_built
65
+ raise Weft::MissingContainerIvar,
66
+ "#{self.class} declared `adds_children_to #{ivar.inspect}` " \
67
+ "but never assigned #{ivar} in build " \
68
+ "(e.g., #{ivar} = div(class: 'body') inside build)"
69
+ else
70
+ super(child)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module DSL
5
+ # Mixin for classes that declare OOB-swapped sibling components.
6
+ # Included into Weft::Component.
7
+ #
8
+ # See Weft::Component#includes for the DSL surface.
9
+ module Inclusions
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ module ClassMethods
15
+ # Declare that another component should be OOB-swapped alongside
16
+ # this component's action responses and SSE pushes.
17
+ #
18
+ # includes OrderHeader # pass-through attrs
19
+ # includes OrderHeader, on: :advance # only on :advance action
20
+ # includes OrderHeader do |attrs| # explicit attr mapping
21
+ # { order_id: attrs.order_id, compact: true }
22
+ # end
23
+ #
24
+ # Without a block, the included component resolves from the same
25
+ # request params as the primary component. With a block, the block
26
+ # receives the primary component's resolved attrs and returns wire
27
+ # attrs for the included component's Resolver.
28
+ def includes(component_class, on: nil, &block)
29
+ own_inclusions << { component_class: component_class, on: on, block: block }
30
+ end
31
+
32
+ # All declared inclusions (own + inherited).
33
+ def inclusions
34
+ if superclass.respond_to?(:inclusions)
35
+ superclass.inclusions + own_inclusions
36
+ else
37
+ own_inclusions.dup
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def own_inclusions
44
+ @own_inclusions ||= []
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module DSL
5
+ # Mixin for classes that declare recovery edges via `recovers`.
6
+ # Used by both Component (action and partial-render failures) and Page
7
+ # (full-document render failures, routing misses).
8
+ #
9
+ # See Weft::Component#recovers for the DSL surface.
10
+ module Recoveries
11
+ def self.included(base)
12
+ base.extend(ClassMethods)
13
+ end
14
+
15
+ module ClassMethods
16
+ # Declare a recovery edge: how this class handles a specific error.
17
+ #
18
+ # recovers from: Weft::Unprocessable do |attrs, error|
19
+ # { error_messages: error.messages }
20
+ # end
21
+ #
22
+ # recovers from: Weft::Unauthorized, with: LoginPage
23
+ #
24
+ # `from:` accepts a Class (subclass-inclusive), Integer (matched against
25
+ # HTTPError#status), Range, or Array of any of the above.
26
+ # `with:` accepts a Class (Page or Component) or Symbol (resolved against
27
+ # Weft.configuration at error-handling time). Default: self.
28
+ # The optional block receives `|attrs, error|` and returns a hash of
29
+ # additional attrs that merge with the original on the recovery edge.
30
+ # Symmetric with performs/transfers contracts (attrs first; error is the
31
+ # recovery-specific extra). The block never returns HTML.
32
+ def recovers(from:, with: nil, &block)
33
+ own_recoveries << { from: from, with: with, block: block }
34
+ end
35
+
36
+ # All declared recovery entries (own + inherited), in resolution order.
37
+ # Own entries precede inherited entries so subclass declarations take
38
+ # precedence over ancestor declarations. Within a class, declaration
39
+ # order is preserved — first-declared wins on ties.
40
+ def recoveries
41
+ if superclass.respond_to?(:recoveries)
42
+ own_recoveries + superclass.recoveries
43
+ else
44
+ own_recoveries.dup
45
+ end
46
+ end
47
+
48
+ # Find the first recovery entry whose `from:` matches the given exception.
49
+ # Returns nil if nothing matches. `from:` accepts Class (subclass-inclusive),
50
+ # Integer (status equality — HTTPError carries .status; non-HTTPError = 500),
51
+ # Range (status in range), or Array of any of the above (any element matches).
52
+ def recovery_for(exception)
53
+ recoveries.find { |entry| recovery_matches?(entry[:from], exception) }
54
+ end
55
+
56
+ # Resolve the recovery entry's `with:` value to a concrete target class.
57
+ # Symbol values look up `Weft.configuration.<sym>` (resolved at error-handling
58
+ # time so config reassignment propagates). Nil falls back to self.
59
+ def resolve_recovery_target(entry)
60
+ case entry[:with]
61
+ when Symbol then Weft.configuration.public_send(entry[:with])
62
+ when nil then self
63
+ else entry[:with]
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def own_recoveries
70
+ @own_recoveries ||= []
71
+ end
72
+
73
+ def recovery_matches?(from_clause, exception)
74
+ case from_clause
75
+ when Array then from_clause.any? { |f| recovery_matches?(f, exception) }
76
+ when Class then exception.is_a?(from_clause)
77
+ when Integer then recovery_status_of(exception) == from_clause
78
+ when Range then from_clause.cover?(recovery_status_of(exception))
79
+ else false
80
+ end
81
+ end
82
+
83
+ def recovery_status_of(exception)
84
+ exception.is_a?(Weft::HTTPError) ? exception.status : 500
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module DSL
5
+ # Mixin for classes that declare HX-Trigger response events.
6
+ # Included into Weft::Component.
7
+ #
8
+ # See Weft::Component#triggers for the DSL surface.
9
+ module Triggers
10
+ def self.included(base)
11
+ base.extend(ClassMethods)
12
+ end
13
+
14
+ module ClassMethods
15
+ # Declare an event to trigger on all action responses for this component.
16
+ # Sets the HX-Trigger response header.
17
+ #
18
+ # triggers "order-updated"
19
+ def triggers(event_name)
20
+ own_triggers << event_name.to_s
21
+ end
22
+
23
+ # All declared trigger events (own + inherited).
24
+ def trigger_events
25
+ if superclass.respond_to?(:trigger_events)
26
+ superclass.trigger_events | own_triggers
27
+ else
28
+ own_triggers.dup
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def own_triggers
35
+ @own_triggers ||= []
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ module DSL
5
+ # Mixin for classes that declare live-update behavior — polling and
6
+ # event-driven refreshes (`refreshes`) and SSE push streams (`pushes`).
7
+ # Included into Weft::Component.
8
+ module Updates
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ # Declare that this component refreshes automatically.
15
+ #
16
+ # refreshes every: 10.seconds # polling
17
+ # refreshes on: "delivery-completed" # event-driven
18
+ # refreshes every: 10, on: "updated" # both
19
+ #
20
+ # Generates hx-get, hx-trigger, hx-swap on the wrapper element.
21
+ # Multiple calls accumulate into a single hx-trigger value.
22
+ # Fractional intervals render in htmx's millisecond syntax.
23
+ def refreshes(every: nil, on: nil)
24
+ if every
25
+ ms = interval_in_ms(every, :refreshes)
26
+ own_refresh_triggers << ((ms % 1000).zero? ? "every #{ms / 1000}s" : "every #{ms}ms")
27
+ end
28
+ own_refresh_triggers << "#{on} from:body" if on
29
+ end
30
+
31
+ # Declare that this component pushes updates via SSE.
32
+ #
33
+ # pushes every: 5.seconds # server pushes on interval
34
+ #
35
+ # Generates hx-ext="sse", sse-connect, sse-swap on the wrapper element.
36
+ # The Router auto-generates a streaming endpoint at
37
+ # /component_path/_stream (the suffix is the stream_suffix config knob).
38
+ #
39
+ # Future: pushes on: "event-name" for event-driven server push (v1.0).
40
+ def pushes(every: nil)
41
+ @push_config = {}
42
+ return unless every
43
+
44
+ ms = interval_in_ms(every, :pushes)
45
+ @push_config[:every] = (ms % 1000).zero? ? ms / 1000 : ms / 1000.0
46
+ end
47
+
48
+ # Push configuration (own or inherited). Returns nil if no pushes declared.
49
+ def push_config
50
+ if instance_variable_defined?(:@push_config)
51
+ @push_config
52
+ elsif superclass.respond_to?(:push_config)
53
+ superclass.push_config
54
+ end
55
+ end
56
+
57
+ # All declared refresh triggers (own + inherited).
58
+ def refresh_triggers
59
+ if superclass.respond_to?(:refresh_triggers)
60
+ superclass.refresh_triggers | own_refresh_triggers
61
+ else
62
+ own_refresh_triggers.dup
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def own_refresh_triggers
69
+ @own_refresh_triggers ||= []
70
+ end
71
+
72
+ # htmx's smallest expressible interval is 1ms; rather than emit an
73
+ # "every 0s" that polls flat-out, round up and say so.
74
+ def interval_in_ms(every, verb)
75
+ ms = (every.to_f * 1000).round
76
+ return ms if ms.positive?
77
+
78
+ Weft.logger.warn(
79
+ "#{name} declares `#{verb} every: #{every.inspect}`, below the 1ms floor; using 1ms"
80
+ )
81
+ 1
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
data/lib/weft/error.rb ADDED
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Weft
4
+ # Abstract base — never raise directly; use a semantic subclass.
5
+ # Exists for `rescue Weft::Error` to catch the whole gem-error family.
6
+ Error = Class.new(StandardError)
7
+
8
+ # Intermediate carrying an HTTP status. Subclass for status-bearing semantics.
9
+ # `recovers from: Weft::HTTPError` catches the whole status-bearing family.
10
+ class HTTPError < Error
11
+ def self.status
12
+ nil
13
+ end
14
+
15
+ def status
16
+ self.class.status
17
+ end
18
+ end
19
+
20
+ class NotFound < HTTPError
21
+ def self.status = 404
22
+ end
23
+
24
+ class Unauthorized < HTTPError
25
+ def self.status = 401
26
+ end
27
+
28
+ class Forbidden < HTTPError
29
+ def self.status = 403
30
+ end
31
+
32
+ class Unprocessable < HTTPError
33
+ def self.status = 422
34
+ end
35
+
36
+ class InternalError < HTTPError
37
+ def self.status = 500
38
+ end
39
+
40
+ # Below this comment, maintain semantically-named errors (not ending in "Error") in alphabetical order, e.g.:
41
+ #
42
+ # OrderAlreadyFulfilled = Class.new(Error)
43
+ # InsufficientFunds = Class.new(Error)
44
+
45
+ # Raised for semantic mistakes inside `Weft.configure { |c| ... }` — values
46
+ # of the right kind that nonetheless violate a constraint (e.g. an asset
47
+ # root not starting with `/`), or state conflicts (duplicate asset bundles).
48
+ InvalidConfiguration = Class.new(Error)
49
+
50
+ # Raised for semantic mistakes in class-body DSL declarations — e.g. a page
51
+ # declares attributes but no `page_path`, or `adds_children_to` receives a
52
+ # Symbol that does not start with `@`.
53
+ InvalidDefinition = Class.new(Error)
54
+
55
+ # Raised for semantic mistakes at render / action time — invalid input
56
+ # combinations or references to state that isn't there (e.g. an unknown
57
+ # assets bundle named at `register_stylesheet`).
58
+ InvalidUsage = Class.new(Error)
59
+
60
+ # Raised by the `adds_children_to :@ivar` macro when build returns without
61
+ # ever assigning the named ivar and then a child is added — almost always
62
+ # means the developer declared the macro but forgot the matching assignment.
63
+ MissingContainerIvar = Class.new(InvalidDefinition)
64
+ end