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
data/lib/weft/resolver.rb CHANGED
@@ -1,32 +1,60 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal/util"
4
+
3
5
  module Weft
4
- # Maps request params (string keys/values) to component attribute hashes.
5
- # Coerces types based on attribute defaults. Future home of the reification
6
- # step (wire primitives rich objects).
6
+ # Projects a wire params hash (string or symbol keys) onto a component
7
+ # class's declared schema, coercing values with declared types.
8
+ # Components self-resolve at build time (see DSL::Params); the Router
9
+ # also calls this directly for error-path bookkeeping. Future home of
10
+ # the reification step (wire primitives → rich objects).
7
11
  class Resolver
8
- def resolve(component_class, params)
9
- component_class.attributes.each_with_object({}) do |(name, meta), result|
10
- raw = params[name.to_s] || params[name]
11
- result[name] = raw.nil? ? meta[:default] : coerce(raw, meta[:default])
12
+ # The declarable wire types, each self-describing: how to coerce a
13
+ # present wire value (permissive Ruby — never raises on malformed
14
+ # input), and the classes a declared default may already be an
15
+ # instance of (declaration-side validation lives in DSL::Params).
16
+ # Self-description is the seam a future registration API extends.
17
+ TYPES = {
18
+ string: { coerce: :to_s.to_proc, classes: [String] },
19
+ integer: { coerce: :to_i.to_proc, classes: [Integer] },
20
+ float: { coerce: :to_f.to_proc, classes: [Float] },
21
+ boolean: { coerce: ->(v) { [true, "true", "1"].include?(v) },
22
+ classes: [TrueClass, FalseClass] },
23
+ decimal: { coerce: :to_d.to_proc, classes: [BigDecimal] }
24
+ }.freeze
25
+
26
+ class << self
27
+ def resolve(component_class, params)
28
+ component_class.params.each_with_object({}) do |(name, meta), result|
29
+ raw = fetch_raw(params, name)
30
+ result[name] = raw.nil? ? meta[:default] : coerce(raw, meta[:type])
31
+ end
12
32
  end
13
- end
14
33
 
15
- private
34
+ # Coerce only the keys actually present on the wire — no default fill.
35
+ # The construction-time source stack uses this to tell wire-satisfied
36
+ # keys apart from keys that fall through to lower sources.
37
+ def resolve_present(component_class, params)
38
+ component_class.params.each_with_object({}) do |(name, meta), result|
39
+ raw = fetch_raw(params, name)
40
+ result[name] = coerce(raw, meta[:type]) unless raw.nil?
41
+ end
42
+ end
43
+
44
+ private
16
45
 
17
- def coerce(value, default)
18
- case default
19
- when Integer then value.to_i
20
- when Float then value.to_f
21
- when true, false then coerce_boolean(value)
22
- else value
46
+ # Try both key shapes without `||` — a literal false must read as present.
47
+ def fetch_raw(params, name)
48
+ key = name.to_s
49
+ key = name unless params.key?(key)
50
+ params[key]
23
51
  end
24
- end
25
52
 
26
- def coerce_boolean(value) # rubocop:disable Naming/PredicateMethod
27
- case value
28
- when true, "true", "1" then true
29
- else false
53
+ # A declared type: coerces via its TYPES entry; untyped params accept
54
+ # any value as-is.
55
+ def coerce(value, type)
56
+ entry = TYPES[type]
57
+ entry ? entry[:coerce].call(value) : value
30
58
  end
31
59
  end
32
60
  end
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "weft/dsl/sandbox"
4
+ require "weft/params"
5
+ require "weft/params/assembly"
6
+ require "weft/redirect"
7
+
3
8
  module Weft
4
9
  class Router
5
10
  # Action-dispatch slice of the Router. Resolves a request path to a
@@ -9,9 +14,10 @@ module Weft
9
14
  # `render_error`; the small `render_action_error` wrapper sets the
10
15
  # destructive-swap header before delegating.
11
16
  #
12
- # Depends on Router internals: `resolver`, `filtered_params`,
13
- # `handle_redirect`, `apply_trigger_header`, `render_oob_includes`,
14
- # `render_error`, `headers`.
17
+ # Depends on Router internals: `filtered_params`, `handle_redirect`,
18
+ # `apply_trigger_header`, `render_error`, `headers`, and the
19
+ # OOB-include slice's `render_companions` / `applicable_inclusions` /
20
+ # `explicitly_named_inclusions`.
15
21
  module Actions
16
22
  private
17
23
 
@@ -40,37 +46,115 @@ module Weft
40
46
  nil
41
47
  end
42
48
 
49
+ # State 1 — what the request composes from the wire before any of the
50
+ # component's own code runs — is the bag the first verb block sees, and
51
+ # it carries every door `build` reads: wire params, derivations (still
52
+ # lazy), and defines. Only `receives` is missing, and structurally so:
53
+ # an action request has no caller and no enclosing build to hand
54
+ # anything over.
55
+ #
56
+ # A failure is recovered from wherever the request had got to, walked
57
+ # from whoever was in charge at the time: the declarer's chain against
58
+ # state 1 while its callable runs, and — once the callable has returned
59
+ # and control has passed on — the rendering component's chain against
60
+ # state 2.
43
61
  def handle_action(action, component_class)
44
- resolved_attrs = resolver.resolve(component_class, filtered_params)
45
- attrs = Weft::Attributes.new(resolved_attrs)
46
-
47
- returned = action.callable&.call(attrs)
62
+ state = Weft::Params::Assembly.for_request(component_class, filtered_params)
63
+ returned = Weft::DSL::Sandbox.run(state, &action.callable) if action.callable
48
64
  return handle_redirect(returned) if returned.is_a?(Weft::Redirect)
65
+ rescue StandardError => e
66
+ render_action_error(action, component_class, state || Weft::Params.new({}), e)
67
+ else
68
+ render_action_response(action, component_class, state, returned)
69
+ end
49
70
 
50
- render_action_response(action, component_class, resolved_attrs, returned)
71
+ # One universe per request: the response renders against the request's
72
+ # own wire, with the callable's returned hash riding as an overlay —
73
+ # every component in the response (the rendered class, its nested
74
+ # children, OOB companions) resolves from the same substrate, and the
75
+ # delta overrides or clears wire values at any depth. The rendered
76
+ # class projects its own schema; the declaring class's resolution
77
+ # never crowns a new universe.
78
+ #
79
+ # Delete-swap actions skip the primary render entirely: htmx discards
80
+ # the body on a delete swap, and the component's record is typically
81
+ # gone by now. OOB includes still ride (a 200, never a 204 — htmx
82
+ # refuses to swap 204s, which would skip the delete itself).
83
+ def render_action_response(action, component_class, state, returned)
84
+ overlay = returned.is_a?(Hash) ? returned : {}
85
+ composed = state.overlay(overlay)
86
+ apply_trigger_header(component_class, action.name)
87
+ slots = Set.new
88
+ primary = build_action_primary(action, overlay, composed, slots)
89
+ (primary ? primary.to_s : "") +
90
+ render_companions(action_companions(action, component_class, primary, composed, overlay), slots)
51
91
  rescue StandardError => e
52
- render_action_error(action, component_class, resolved_attrs || {}, e)
92
+ render_action_error(action, action.renders, composed, e)
93
+ end
94
+
95
+ # Which companions ride this response, in precedence order: the
96
+ # rendered component's first, then — on a transfer — the declaring
97
+ # component's explicitly named ones. One list, so two companions
98
+ # claiming a single DOM id are caught across the two sources and the
99
+ # rendered component's declaration keeps the slot.
100
+ def action_companions(action, component_class, primary, composed, overlay)
101
+ target = target_companions(action, component_class, primary, composed, overlay)
102
+ return target if action.renders.equal?(component_class)
103
+
104
+ target + declarer_companions(component_class, action.name, composed, overlay)
105
+ end
106
+
107
+ # The rendered component's companions: its block reads the primary's
108
+ # rendered bag (rich values included) and each one branches it.
109
+ def target_companions(action, component_class, primary, composed, overlay)
110
+ context = action.renders.equal?(component_class) ? :action : :transfer
111
+ env = { universe: filtered_params, overlays: overlay, branch_bag: primary&.params }
112
+ view = includes_view(primary, composed, overlay)
113
+ applicable_inclusions(action.renders, context, action.name).map { |inc| [inc, view, env] }
114
+ end
115
+
116
+ # The declaring component's companions on a transfer. This branch
117
+ # forks before the hand-off, so the block reads the declarer's own
118
+ # params plus the callable's overlay — never the target's picture —
119
+ # and there is no primary bag to branch, because nothing rendered the
120
+ # declarer and so no rich values exist on this path.
121
+ def declarer_companions(component_class, action_name, composed, overlay)
122
+ env = { universe: filtered_params, overlays: overlay }
123
+ explicitly_named_inclusions(component_class, action_name).map { |inc| [inc, composed, env] }
124
+ end
125
+
126
+ # Delete-swap actions skip the primary render: htmx discards the body
127
+ # on a delete swap, and the component's record is typically gone.
128
+ # The primary claims its DOM slot first, so a companion aimed at the
129
+ # same id is turned away rather than swapping over the very fragment
130
+ # the response is about.
131
+ #
132
+ # The primary branches the state the request has already composed, so a
133
+ # derivation the callable forced isn't paid for twice — and a transfer
134
+ # target inherits it exactly as a nested child inherits its parent's.
135
+ # Declared defaults don't ride a branch, so the target's own fallbacks
136
+ # stay its own; to override an inherited value, return the key (an
137
+ # explicit nil clears it).
138
+ def build_action_primary(action, overlay, composed, slots)
139
+ return nil if action.swap == :delete
140
+
141
+ build_component_with_wire(action.renders, filtered_params, overlays: overlay,
142
+ branch_bag: composed, slots: slots)
53
143
  end
54
144
 
55
- # Successive resolution across the component-class boundary. The bag
56
- # accumulates the declaring component's resolved attrs plus any hash the
57
- # callable returned; the rendered class then runs its OWN resolution pass
58
- # over the bag, so only its declared attributes reach the builder splat
59
- # (closing the cross-class leak). The bag itself keeps every key so
60
- # downstream OOB includes still see callable-returned attrs.
61
- def render_action_response(action, component_class, resolved_attrs, returned)
62
- bag = returned.is_a?(Hash) ? resolved_attrs.merge(returned) : resolved_attrs
63
- apply_trigger_header(component_class)
64
- html = action.renders.render(**resolver.resolve(action.renders, bag))
65
- html + render_oob_includes(component_class, Weft::Attributes.new(bag), action_name: action.name)
145
+ # The params view an inclusion block receives: the rendered primary's
146
+ # bag with the overlay applied (undeclared delta keys stay readable).
147
+ # On a delete-swap there is no primary the composed state stands in.
148
+ def includes_view(primary, composed, overlay)
149
+ primary ? primary.params.overlay(overlay) : composed
66
150
  end
67
151
 
68
152
  # Error handling for actions. Adds HX-Reswap header when the action's
69
153
  # swap strategy is destructive (e.g., :delete) so the error fragment
70
154
  # renders visibly instead of the element being silently removed.
71
- def render_action_error(action, component_class, resolved_attrs, error)
155
+ def render_action_error(action, component_class, resolved_params, error)
72
156
  headers["HX-Reswap"] = "outerHTML" if action.swap == :delete
73
- render_error(component_class, resolved_attrs, error)
157
+ render_error(component_class, resolved_params, error)
74
158
  end
75
159
  end
76
160
  end