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
data/docs/arbre.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# Arbre: the HTML layer
|
|
2
|
+
|
|
3
|
+
Weft builds HTML with [Arbre](https://github.com/activeadmin/arbre), the object-oriented HTML builder from the ActiveAdmin family. This isn't an implementation detail you can ignore: **every `Weft::Component` and `Weft::Page` *is* an Arbre component.** The `build` method you write, the `builder_method` you declare, the `h1`/`div`/`para` calls inside — all Arbre. When Weft feels like "describing the UI in Ruby," Arbre is the part doing the describing.
|
|
4
|
+
|
|
5
|
+
Arbre's own upstream documentation is famously thin, so this document doesn't assume you'll fill gaps elsewhere. It's the working knowledge a Weft author needs — Arbre's model, its conventions, and its genuinely surprising corners — shown the way you'll use it in Weft. Everything here is verified against the Arbre versions Weft supports (1.7 through 2.x; the handful of behavioral differences are [at the end](#arbre-1x-vs-2x)).
|
|
6
|
+
|
|
7
|
+
**In this document:**
|
|
8
|
+
|
|
9
|
+
- [The element tree](#the-element-tree)
|
|
10
|
+
- [Building HTML](#building-html): [elements and arguments](#elements-and-arguments), [attributes](#attributes), [text](#text)
|
|
11
|
+
- [Blocks and method lookup](#blocks-and-method-lookup)
|
|
12
|
+
- [Inside `build`: the component contract](#inside-build-the-component-contract)
|
|
13
|
+
- [Receiving caller content](#receiving-caller-content)
|
|
14
|
+
- [Working with the tree](#working-with-the-tree)
|
|
15
|
+
- [Forms](#forms)
|
|
16
|
+
- [Testing components](#testing-components)
|
|
17
|
+
- [Arbre 1.x vs 2.x](#arbre-1x-vs-2x)
|
|
18
|
+
|
|
19
|
+
## The element tree
|
|
20
|
+
|
|
21
|
+
Arbre doesn't concatenate strings — it builds a tree of Ruby objects that mirrors your HTML, and renders it with `to_s` at the end. Every `div` or `span` you create is an object (`Arbre::HTML::Div`, `Arbre::HTML::Span`…) with a parent, children, attributes, and methods. Until rendering, the tree is live: you can hold references to elements, add to them out of order, inspect them, move them.
|
|
22
|
+
|
|
23
|
+
The mechanism that makes nested blocks work is the **current element**. There's always exactly one element currently receiving new children. When you call `div do ... end`, Arbre:
|
|
24
|
+
|
|
25
|
+
1. creates the `Div`,
|
|
26
|
+
2. adds it as a child of the current element,
|
|
27
|
+
3. makes the new div the current element,
|
|
28
|
+
4. runs your block (so everything created inside lands in the div),
|
|
29
|
+
5. restores the previous current element.
|
|
30
|
+
|
|
31
|
+
That's the whole trick. Every pattern in this document — containers, `within`, method lookup — is a variation on "who is the current element right now, and where do new children go?"
|
|
32
|
+
|
|
33
|
+
## Building HTML
|
|
34
|
+
|
|
35
|
+
### Elements and arguments
|
|
36
|
+
|
|
37
|
+
Every HTML5 element is available as a builder method: `div`, `span`, `table`, `nav`, `section`, `input`, `select` — all of them. Each accepts the same argument convention:
|
|
38
|
+
|
|
39
|
+
| Form | Example | Meaning |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| bare | `hr` | empty element |
|
|
42
|
+
| string first | `h1 "Trivia Night"` | first non-hash argument becomes the content |
|
|
43
|
+
| hash last | `div class: "event-card"` | trailing hash becomes the attributes |
|
|
44
|
+
| both | `h1 "Trivia Night", class: "title"` | content and attributes |
|
|
45
|
+
| block | `div(class: "event-card") { h1 "..." }` | block builds the children |
|
|
46
|
+
|
|
47
|
+
Self-closing tags (`br`, `hr`, `img`, `input`, `meta`, `link`, and friends) close themselves — `img src: "map.png"` renders `<img src="map.png"/>`.
|
|
48
|
+
|
|
49
|
+
> **The one exception: paragraphs are `para`, not `p`.** Ruby's built-in `Kernel#p` (the debug printer) can't be shadowed safely, so Arbre names the `<p>` builder `para`. If you write `p "some text"`, nothing errors — the text goes to your server's stdout and no paragraph renders. This costs every newcomer an hour once; the [tutorial](tutorial.md#3-your-first-page) tries to make sure it isn't you.
|
|
50
|
+
|
|
51
|
+
### Attributes
|
|
52
|
+
|
|
53
|
+
Set attributes at creation time (the trailing hash), or programmatically on the element:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
div class: "event-card", id: "bbq"
|
|
57
|
+
|
|
58
|
+
div do |card|
|
|
59
|
+
card.set_attribute "aria-live", "polite"
|
|
60
|
+
card.add_class "highlighted"
|
|
61
|
+
card.remove_class "pending"
|
|
62
|
+
card.id = "custom-id"
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`get_attribute`, `has_attribute?`, and `remove_attribute` round out the set, and `class_list` returns the classes as an inspectable collection.
|
|
67
|
+
|
|
68
|
+
Hashes under `data:` flatten into hyphenated data attributes, nesting included:
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
div data: { controller: "chart", series: { color: "teal" } }
|
|
72
|
+
# => <div data-controller="chart" data-series-color="teal"></div>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Attributes with `nil` values are omitted from the output. (Empty-*string* values differ by Arbre version — [see below](#arbre-1x-vs-2x).)
|
|
76
|
+
|
|
77
|
+
### Text
|
|
78
|
+
|
|
79
|
+
Four ways to put text in the tree:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
span "Priya" # 1. as the content argument — the usual way
|
|
83
|
+
span { "Priya" } # 2. as the block's return value
|
|
84
|
+
text_node "3 attending" # 3. explicitly, wherever you are
|
|
85
|
+
text_node "<em>live</em>".html_safe # 4. raw HTML — bypasses escaping, trusted content only
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Way 2 comes with the sharpest edge in Arbre. A block's return value becomes text **only if the element has no children yet**; the moment anything else was added, the return value is silently discarded:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
li { "Priya" } # <li>Priya</li> — works
|
|
92
|
+
li do
|
|
93
|
+
strong "Priya"
|
|
94
|
+
" — yes" # silently discarded!
|
|
95
|
+
end # <li><strong>Priya</strong></li>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
No error, no warning — the text just isn't there. Whenever a block mixes elements and loose text, use `text_node` for the text:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
li do
|
|
102
|
+
strong "Priya"
|
|
103
|
+
text_node " — yes" # <li><strong>Priya</strong> — yes</li>
|
|
104
|
+
end
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Blocks and method lookup
|
|
108
|
+
|
|
109
|
+
When you call a method inside an Arbre block, what actually receives it? Two rules cover nearly everything:
|
|
110
|
+
|
|
111
|
+
**Rule 1 — the routing chain.** A method the enclosing object doesn't itself define is routed by Arbre, in order: methods on the **current element** first (that's how `add_class` or `set_attribute` work bare inside a block), then keys in the context's **assigns** hash, then the **helpers** object, and finally a normal `NoMethodError`. Inside a Weft component you rarely think about assigns and helpers — your component's own methods and ordinary Ruby scope do the work — but the chain matters when you use `Arbre::Context` directly ([Testing](#testing-components)) and when names collide (below).
|
|
112
|
+
|
|
113
|
+
**Rule 2 — real methods win.** The routing only happens via `method_missing`, so a method that *does* exist on the enclosing object binds there, not to the element you're inside. Your component's own helper methods work naturally inside nested blocks for exactly this reason (during `build`, the enclosing object is your component). But the same rule has a trap: generic element methods like `add_child`, `content`, and `parent` exist on *every* Arbre object — including your component and the root context — so calling them bare inside a `div` block does not touch the div. When you mean the element, take it as a block parameter and be explicit:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
div do |d|
|
|
117
|
+
d.add_child something # unambiguously the div
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
(Tag-specific methods — `add_class`, `set_attribute`, `id=` — aren't defined on components or contexts, so they route to the current element reliably. It's the tree-plumbing methods that need the explicit receiver.)
|
|
122
|
+
|
|
123
|
+
**Name collisions** follow from rule 1's element check: every HTML tag name is a method on the current element. A local, an assign, or a model reference named `address`, `time`, `data`, `table`, or any other tag name loses to the tag builder:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
address = venue_address(event)
|
|
127
|
+
div do
|
|
128
|
+
address # builds an empty <address> element — not your variable!
|
|
129
|
+
end
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Rename the variable (`venue`), or hold data in something the chain checks earlier. This one is worth remembering any time an inexplicably empty element shows up in your output.
|
|
133
|
+
|
|
134
|
+
## Inside `build`: the component contract
|
|
135
|
+
|
|
136
|
+
A component describes its structure in `build`. The Weft-idiomatic shape takes a single attributes hash and calls `super` before building:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
class EventSummary < Weft::Component
|
|
140
|
+
builder_method :event_summary
|
|
141
|
+
|
|
142
|
+
attribute :event_id
|
|
143
|
+
|
|
144
|
+
def build(attributes = {})
|
|
145
|
+
super
|
|
146
|
+
event = EventStore.find(attrs.event_id)
|
|
147
|
+
h3 event.name
|
|
148
|
+
para "#{event.date} — #{event.location}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Arguments arrive positionally — always.** When a call site writes `event_summary(event_id: "bbq", class: "compact")`, Arbre collects the arguments and passes them positionally to `build`; the keywords become one trailing hash. Declaring Ruby keyword parameters (`def build(event_id:)`) raises `ArgumentError: wrong number of arguments`. Take the hash.
|
|
154
|
+
|
|
155
|
+
**`super` is where the hash becomes reality.** In a Weft component, `super` extracts your declared attributes into `attrs`, applies whatever remains as HTML attributes on the wrapper element (that's where `class: "compact"` went), and sets the wrapper's DOM id ([derived from your first attribute](dsl.md#attributes)). Skip `super` and none of that happens — the classic symptom is a component that ignores the `class:` you pass it.
|
|
156
|
+
|
|
157
|
+
**Rich objects ride the same hash — pull them out first.** Wire attributes (declared with `attribute`) are for values that travel in URLs. When a call site already holds a rich object, passing it in the hash is fine — just `delete` it before `super` so it doesn't get sprayed onto the wrapper as an HTML attribute:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
class AttendeeRow < Weft::Component
|
|
161
|
+
builder_method :attendee_row
|
|
162
|
+
|
|
163
|
+
def build(attributes = {})
|
|
164
|
+
@attendee = attributes.delete(:attendee) # rich object out first
|
|
165
|
+
super
|
|
166
|
+
td @attendee.name
|
|
167
|
+
td @attendee.answer
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def tag_name
|
|
171
|
+
"tr"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**`tag_name` picks the wrapper element.** Components render as `<div>` by default; override `tag_name` to be a `tr`, `span`, `li`, `section` — whatever the surrounding HTML demands, as above.
|
|
177
|
+
|
|
178
|
+
**`builder_method` resolves by name, at call time.** The macro generates a method that looks the class up as a constant (`insert_tag ::AttendeeRow`) each time it's called. Two consequences: it plays well with code reloading (the freshest definition wins), and it requires the class to be a real, resolvable constant — which is mostly invisible in an app but occasionally bites in tests ([below](#testing-components)).
|
|
179
|
+
|
|
180
|
+
## Receiving caller content
|
|
181
|
+
|
|
182
|
+
Composable components take a block of caller content:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
event_card(event_id: event.id) do
|
|
186
|
+
para "Bring a dish to share!"
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
One fact makes this work, and it's worth internalizing: **the caller's block runs *after* `build` returns.** First your `build` lays out the component's structure; then the caller's children arrive, one `add_child` at a time. Anything your `build` set up — including instance variables — is in place by the time they do.
|
|
191
|
+
|
|
192
|
+
By default those children land on the wrapper element, after your structure. When they should land *inside* a specific element instead — the card's body, not next to its header — declare the container with Weft's macro:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
class EventCard < Weft::Component
|
|
196
|
+
builder_method :event_card
|
|
197
|
+
adds_children_to :@body
|
|
198
|
+
|
|
199
|
+
attribute :event_id
|
|
200
|
+
|
|
201
|
+
def build(attributes = {})
|
|
202
|
+
super
|
|
203
|
+
h3 EventStore.find(attrs.event_id).name # structure — lands on the wrapper
|
|
204
|
+
@body = div(class: "event-card-body") # caller content lands in here
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The macro generates the underlying Arbre pattern: an `add_child` override that redirects to `@body` once it exists (during `build` it doesn't yet, so your own structural elements pass through normally). The `:@ivar` spelling is a deliberate reminder that *you* must assign that ivar in `build` — Weft raises a pointed error if you forget. Details in [the DSL reference](dsl.md#other-class-body-declarations).
|
|
210
|
+
|
|
211
|
+
Hand-roll the override only when one redirect target isn't enough — a multi-slot component with `header`/`body`/`footer` sections, say, where each section method fills a different internal element and `add_child` picks a default. The generated pattern above is the template to follow.
|
|
212
|
+
|
|
213
|
+
## Working with the tree
|
|
214
|
+
|
|
215
|
+
Because elements are objects, you can build out of order. Hold a reference, come back later with `within`:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
def build(attributes = {})
|
|
219
|
+
super
|
|
220
|
+
@summary = div(class: "summary")
|
|
221
|
+
@details = div(class: "details")
|
|
222
|
+
|
|
223
|
+
within @details do
|
|
224
|
+
para "Doors at 6:30" # lands in @details
|
|
225
|
+
end
|
|
226
|
+
within @summary do
|
|
227
|
+
para "21 attending" # lands in @summary — after @details was filled
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
`within(element) { ... }` temporarily makes any element the current one. It's the tool for components whose sections fill up at different times.
|
|
233
|
+
|
|
234
|
+
For inspection, the tree is searchable: `find_by_tag("a")` and `find_by_class("nav-item")` return matching descendants, `parent` and `ancestors` walk upward, and `children` enumerates directly. These shine in tests ([below](#testing-components)) and in the rare component that post-processes its own output.
|
|
235
|
+
|
|
236
|
+
Two content accessors to keep straight: `content` *reads* the children rendered as HTML — but `content=` **replaces**, clearing every existing child first:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
div do |d|
|
|
240
|
+
span "gone"
|
|
241
|
+
d.content = "replacement" # the span is destroyed
|
|
242
|
+
end # <div>replacement</div>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
It's the right tool for wholesale swaps and a footgun everywhere else. To add without destroying, create elements normally or use `text_node`.
|
|
246
|
+
|
|
247
|
+
## Forms
|
|
248
|
+
|
|
249
|
+
Forms are ordinary Arbre: `form`, `label`, `input`, `select`, `option`, `textarea` are tag builders like any other, and you compose them like any other markup. What Weft adds is the wiring — `form(action: :submit)` connects the form to a declared action, with htmx submission and a no-JavaScript fallback emitted for free ([the DSL reference](dsl.md#action) has the mechanics; the [tutorial](tutorial.md#7-taking-rsvps) builds a full working form).
|
|
250
|
+
|
|
251
|
+
Two field-level idioms worth knowing:
|
|
252
|
+
|
|
253
|
+
- **Field names pair with declared attributes.** In a Weft form, an `input name: "answer"` reaches the action callable as `attrs.answer` when the component declares `attribute :answer` — and component attributes that *aren't* form fields need a hidden input to travel. The [tutorial](tutorial.md#7-taking-rsvps) walks through both halves.
|
|
254
|
+
- **Array parameters use the `name[]` convention:** `input type: "checkbox", name: "toppings[]", value: "olives"` — submitted values arrive as an array.
|
|
255
|
+
|
|
256
|
+
## Testing components
|
|
257
|
+
|
|
258
|
+
Weft components render to a string with one call — no server, no request:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
RSpec.describe AttendeeList do
|
|
262
|
+
it "lists each attendee with their answer" do
|
|
263
|
+
html = AttendeeList.render(event_id: "trivia-night")
|
|
264
|
+
expect(html).to include("Priya — yes")
|
|
265
|
+
expect(html).to include('id="attendee-list-trivia-night"')
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`Component.render(**attrs)` is the gem-provided entry point and covers most component testing. When you want the element tree rather than the string — asserting on classes, structure, or specific descendants — build a context and search it:
|
|
271
|
+
|
|
272
|
+
```ruby
|
|
273
|
+
ctx = Arbre::Context.new do
|
|
274
|
+
attendee_list(event_id: "trivia-night")
|
|
275
|
+
end
|
|
276
|
+
list = ctx.children.first
|
|
277
|
+
expect(list.class_list).to include("roster")
|
|
278
|
+
expect(ctx.find_by_tag("li").length).to eq(2)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Three Arbre-specific notes for test code:
|
|
282
|
+
|
|
283
|
+
- **Give test component classes real names.** `builder_method` resolves its class by name at call time, so an anonymous class (`Class.new(Weft::Component)`) with a stubbed `name` raises `NameError` the first time its builder is invoked — and under Arbre 1.x, even `insert_tag` with a truly anonymous class crashes. Define named classes (a `TestCard = Class.new(...)` constant works) rather than fighting it.
|
|
284
|
+
- **Pass data via assigns, not local-variable capture,** when a context block needs outside data: `Arbre::Context.new(event: event) { ... }` makes `event` resolve through Arbre's lookup chain — the same resolution production code uses — where a captured local would quietly bypass it.
|
|
285
|
+
- **The helpers slot** (`Arbre::Context.new(assigns, helpers)`) makes any object's methods callable inside the block. Weft renders components without helpers, so tests should too, unless you're testing raw Arbre code that expects them.
|
|
286
|
+
|
|
287
|
+
## Arbre 1.x vs 2.x
|
|
288
|
+
|
|
289
|
+
Weft supports Arbre 1.7 through 2.x — relevant if your app also carries ActiveAdmin, which historically pins Arbre 1.x. The behavioral differences, verified against both:
|
|
290
|
+
|
|
291
|
+
| Behavior | Arbre 1.x | Arbre 2.x |
|
|
292
|
+
| --- | --- | --- |
|
|
293
|
+
| Component CSS class | auto-adds the snake-cased class name (`class="fancy_box"`) | nothing added automatically |
|
|
294
|
+
| `<table>` attributes | auto-adds `border="0" cellspacing="0" cellpadding="0"` | nothing added |
|
|
295
|
+
| Empty-string attribute values | omitted from output | rendered (`foo=""`) |
|
|
296
|
+
| Anonymous component classes | `insert_tag` crashes (`undefined method 'demodulize' for nil`) | works |
|
|
297
|
+
|
|
298
|
+
Writing code that behaves identically on both comes down to two habits: **add your CSS classes explicitly** (never lean on 1.x's auto-class — under 2.x your styling silently disappears), and **name your component classes** (see [Testing](#testing-components)). If you're styling tables, remember 1.x injects those legacy attributes into your output.
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
Weft configures itself with sensible defaults — a new app needs no configuration at all. When you do want to change something, every gem-level setting lives on a single configuration object, set inside a `Weft.configure` block:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
Weft.configure do |c|
|
|
7
|
+
c.log_level = :debug
|
|
8
|
+
c.static_assets root: "/static", from: File.join(APP_ROOT, "public")
|
|
9
|
+
end
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Call `Weft.configure` from your boot file (typically `config/environment.rb`), after `require "weft"` and before the first request. Settings are validated as they're assigned, so a typo'd value raises immediately at boot rather than misbehaving later. After the block runs, Weft applies any side effects the new settings imply (mounting static asset routes, enabling the reloader, setting the logger level). Calling `Weft.configure` more than once is fine — each call re-applies these side effects idempotently.
|
|
13
|
+
|
|
14
|
+
Every setting, at a glance:
|
|
15
|
+
|
|
16
|
+
| Setting | Default | Purpose |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| [`auto_reload`](#auto_reload) | `false` | Enable code reloading on the Router during development. |
|
|
19
|
+
| [`reload_paths`](#reload_paths) | `[]` | Files the reloader should watch, as glob patterns. |
|
|
20
|
+
| [`router_logging`](#router_logging) | `false` | Request logging on the Router. |
|
|
21
|
+
| [`log_level`](#log_level) | `:info` | Severity threshold for `Weft.logger`. |
|
|
22
|
+
| [`include_htmx`](#include_htmx) | `true` | Pages include the htmx script automatically. |
|
|
23
|
+
| [`include_sse_ext`](#include_sse_ext) | `:auto` | Pages include the htmx SSE extension when needed. |
|
|
24
|
+
| [`static_assets`](#static_assets) | none | Serve a directory of files at a URL prefix. |
|
|
25
|
+
| [`component_path`](#component_path) | derives `/_components/<name>` | How a component class maps to its route. |
|
|
26
|
+
| [`stream_suffix`](#stream_suffix) | `"_stream"` | Path segment for SSE stream endpoints. |
|
|
27
|
+
| [`error_component`](#the-four-fallback-targets) | `Weft::Defaults::ErrorComponent` | Fragment rendered when a component fails. |
|
|
28
|
+
| [`error_page`](#the-four-fallback-targets) | `Weft::Defaults::ErrorPage` | Document rendered when a page fails. |
|
|
29
|
+
| [`not_found_component`](#the-four-fallback-targets) | `Weft::Defaults::NotFoundComponent` | Fragment rendered for a component-context 404. |
|
|
30
|
+
| [`not_found_page`](#the-four-fallback-targets) | `Weft::Defaults::NotFoundPage` | Document rendered for an unmatched route. |
|
|
31
|
+
| [`verbose_error_pages`](#verbose_error_pages) | `true` | Fallback renderings show exception details. |
|
|
32
|
+
| [`htmx_errors`](#htmx_errors) | `:fragment` | How htmx requests present fallback errors. |
|
|
33
|
+
|
|
34
|
+
## Development
|
|
35
|
+
|
|
36
|
+
### `auto_reload`
|
|
37
|
+
|
|
38
|
+
Default: `false`.
|
|
39
|
+
|
|
40
|
+
When `true`, Weft registers `Sinatra::Reloader` on `Weft::Router`, so code changes are picked up without restarting the server. Off by default; flip it on however you detect development mode:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
Weft.configure do |c|
|
|
44
|
+
c.auto_reload = (ENV.fetch("RACK_ENV", "production") == "development")
|
|
45
|
+
c.reload_paths = [File.expand_path("app/**/*.rb", __dir__)]
|
|
46
|
+
end
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The reloader is registered once, the first time a `Weft.configure` call sees `auto_reload` set to `true` — it can't be unregistered afterward, and `reload_paths` entries added in later `configure` calls won't be picked up. Set both together, as above.
|
|
50
|
+
|
|
51
|
+
`auto_reload` suits apps that don't already have a reloading story. If your app manages its own constant loading with Zeitwerk, you may prefer to drive reloading yourself (`loader.reload` in a `Weft::Router.before` block) and leave this off. Either way, Weft's registry tolerates reloading: when a class is redefined, the stale registration is pruned automatically (see [Routing](routing.md)).
|
|
52
|
+
|
|
53
|
+
### `reload_paths`
|
|
54
|
+
|
|
55
|
+
Default: `[]`.
|
|
56
|
+
|
|
57
|
+
Glob patterns added to the reloader's watch list. Without this, `Sinatra::Reloader` only watches files where Weft defines its own routes — meaning edits to *your* components and pages would go unnoticed. Point it at your application code:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
c.reload_paths = [
|
|
61
|
+
File.expand_path("app/**/*.rb", __dir__),
|
|
62
|
+
File.expand_path("config/**/*.rb", __dir__)
|
|
63
|
+
]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Only meaningful alongside `auto_reload = true`, and must be set in the same `configure` call (or an earlier one).
|
|
67
|
+
|
|
68
|
+
### `router_logging`
|
|
69
|
+
|
|
70
|
+
Default: `false`.
|
|
71
|
+
|
|
72
|
+
When `true`, enables Sinatra's request logging on `Weft::Router`, so each request the Router serves is logged in the usual access-log format. Useful in development, or temporarily in production when debugging.
|
|
73
|
+
|
|
74
|
+
### `log_level`
|
|
75
|
+
|
|
76
|
+
Default: `:info`.
|
|
77
|
+
|
|
78
|
+
The severity threshold applied to `Weft.logger`. Any standard `Logger` severity symbol works: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, `:unknown`.
|
|
79
|
+
|
|
80
|
+
`Weft.logger` itself is assignable. It defaults to a `$stdout` logger — the unified activity-and-error stream a twelve-factor deployment expects — but you can point it anywhere:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
Weft.logger = Rails.logger
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Assign your logger *before* calling `Weft.configure`; the configure step applies `log_level` to whatever logger is current at that moment (mutating its `level`).
|
|
87
|
+
|
|
88
|
+
## Pages and assets
|
|
89
|
+
|
|
90
|
+
### `include_htmx`
|
|
91
|
+
|
|
92
|
+
Default: `true`.
|
|
93
|
+
|
|
94
|
+
`Weft::Page` automatically includes the htmx script in its `<head>`, loaded from a CDN at a version pinned by the gem, with a subresource-integrity hash. This is what makes a freshly generated page interactive with zero setup.
|
|
95
|
+
|
|
96
|
+
Set it to `false` to take control of htmx delivery yourself — for example, to self-host the file or bundle it with other scripts. You're then responsible for getting htmx onto the page (`register_script` on your base page class is the natural spot).
|
|
97
|
+
|
|
98
|
+
### `include_sse_ext`
|
|
99
|
+
|
|
100
|
+
Default: `:auto`.
|
|
101
|
+
|
|
102
|
+
Controls whether `Weft::Page` also includes the htmx SSE extension script, which components declaring `pushes` need for their live connections. Three values:
|
|
103
|
+
|
|
104
|
+
- `:auto` — include it only if some registered component declares `pushes`. The right answer for almost everyone: apps with no SSE components don't ship the extra script, and apps with them don't need to remember it.
|
|
105
|
+
- `true` — always include it. An escape hatch for setups where components load lazily and might not be registered yet when the first page renders, so `:auto` would miss them.
|
|
106
|
+
- `false` — never include it. Pair with self-hosting, as with `include_htmx`.
|
|
107
|
+
|
|
108
|
+
### `static_assets`
|
|
109
|
+
|
|
110
|
+
Default: none configured.
|
|
111
|
+
|
|
112
|
+
Registers a directory to be served at a URL prefix — Weft's minimal answer to serving your CSS, JavaScript, and images without a separate file-server layer:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
Weft.configure do |c|
|
|
116
|
+
c.static_assets root: "/static", from: File.join(APP_ROOT, "public")
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
With that in place, a request for `/static/css/app.css` serves `public/css/app.css` (GET and HEAD only, with content-type and freshness headers handled for you, and path-traversal attempts rejected).
|
|
121
|
+
|
|
122
|
+
Unlike the other settings, `static_assets` is a method, not an assignment, and it can be called multiple times to register multiple *bundles* — each a named root/directory pair:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
c.static_assets root: "/static", from: File.join(APP_ROOT, "public") # the :default bundle
|
|
126
|
+
c.static_assets name: :vendor, root: "/vendor", from: VENDOR_DIR
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Bundle names are the stable reference used by pages. When a page registers an asset by bare relative path, the path resolves against a bundle's root — the one named by the `assets:` kwarg, or the `:default` bundle when the kwarg is omitted:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
class ApplicationPage < Weft::Page
|
|
133
|
+
register_stylesheet "css/app.css" # → /static/css/app.css
|
|
134
|
+
register_script "charts.js", assets: :vendor # → /vendor/charts.js
|
|
135
|
+
register_stylesheet "https://cdn.example.com/x.css" # absolute — used as-is
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
This indirection is the point of naming bundles: call sites reference a logical name, and the physical root and directory can be reconfigured (say, from environment variables in production) without touching any page class. Absolute URLs (`http://`, `https://`, `//`, or a leading `/`) always pass through unchanged; combining one with an `assets:` kwarg raises, since the kwarg would have no effect.
|
|
140
|
+
|
|
141
|
+
Any extra kwargs on `register_script` render as attributes on the emitted `<script>` tag — `defer: true`, `type: "module"`, and notably the subresource-integrity pair for third-party CDN scripts:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
register_script "https://cdn.example.com/widgets.js",
|
|
145
|
+
integrity: "sha384-…", crossorigin: "anonymous"
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
(The htmx core and SSE-extension scripts Weft includes itself are integrity-pinned the same way.)
|
|
149
|
+
|
|
150
|
+
Registering a duplicate bundle name, or a second bundle at the same root, raises `Weft::InvalidConfiguration` — bundles are declared once, at boot.
|
|
151
|
+
|
|
152
|
+
## Routing
|
|
153
|
+
|
|
154
|
+
These two settings shape the URLs Weft generates. The full routing model — what gets a route, how collisions are detected, how pages route — lives in [Routing](routing.md).
|
|
155
|
+
|
|
156
|
+
### `component_path`
|
|
157
|
+
|
|
158
|
+
Default: a proc deriving `/_components/<name>`.
|
|
159
|
+
|
|
160
|
+
Maps a component class to its route path. The value must be a proc; it receives the class and returns the path string. The default strips a trailing `Component` from the class name, snake-cases what's left, and prefixes `/_components/` — so `OrdersPanelComponent` (or plain `OrdersPanel`) routes at `/_components/orders_panel`, and a namespaced `Admin::OrdersPanel` routes at `/_components/admin/orders_panel`.
|
|
161
|
+
|
|
162
|
+
Replace it to change the convention app-wide:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
c.component_path = ->(klass) { "/partials/#{klass.name.underscore}" }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
For a one-off exception, don't reconfigure the gem — set `self.component_path = "/somewhere/else"` (a string or a proc) on the individual class instead. The class-level setting wins over this knob.
|
|
169
|
+
|
|
170
|
+
### `stream_suffix`
|
|
171
|
+
|
|
172
|
+
Default: `"_stream"`.
|
|
173
|
+
|
|
174
|
+
The path segment marking a component's SSE stream endpoint. A component declaring `pushes` gets its stream served at `<component path>/<stream_suffix>` — by default, `/_components/order_feed/_stream`. The setting is used on both sides of the connection: it's baked into the `sse-connect` URL the component renders, and it's how the Router recognizes stream requests.
|
|
175
|
+
|
|
176
|
+
Set a bare path segment — no slashes; Weft supplies the separator. The default's leading underscore keeps stream endpoints out of the namespace of derived component paths (which, coming from Ruby class names, never begin with an underscore).
|
|
177
|
+
|
|
178
|
+
## Error handling
|
|
179
|
+
|
|
180
|
+
Four settings name the fallback render targets Weft uses when something raises and no user-declared `recovers` entry intercepts it; two more shape how those fallbacks present. The full story — error classes, `recovers` chains, auto-injected attributes — lives in [Error handling](error-handling.md).
|
|
181
|
+
|
|
182
|
+
### The four fallback targets
|
|
183
|
+
|
|
184
|
+
| Setting | Default | Rendered when… |
|
|
185
|
+
| --- | --- | --- |
|
|
186
|
+
| `error_component` | `Weft::Defaults::ErrorComponent` | a component render or action raises |
|
|
187
|
+
| `error_page` | `Weft::Defaults::ErrorPage` | a full-document page render raises |
|
|
188
|
+
| `not_found_component` | `Weft::Defaults::NotFoundComponent` | `Weft::NotFound` is raised in a component context |
|
|
189
|
+
| `not_found_page` | `Weft::Defaults::NotFoundPage` | no route matches, or a page raises `Weft::NotFound` |
|
|
190
|
+
|
|
191
|
+
Assign your own subclasses to brand these app-wide:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
Weft.configure do |c|
|
|
195
|
+
c.error_component = MyApp::ErrorComponent
|
|
196
|
+
c.not_found_page = MyApp::NotFoundPage
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
The gem's built-in recovery chain resolves these settings at error-handling time, not at boot — so reassigning a knob propagates everywhere immediately, without re-declaring `recovers` on your classes. When one class needs different behavior than the rest of the app, declare an explicit `recovers` on it instead of reconfiguring the gem; see [Error handling](error-handling.md).
|
|
201
|
+
|
|
202
|
+
### `verbose_error_pages`
|
|
203
|
+
|
|
204
|
+
Default: `true`.
|
|
205
|
+
|
|
206
|
+
When `true`, the gem-default fallbacks show diagnostic detail: the error component displays the exception class and message, and the not-found component displays the requested path. Set it to `false` in production deploys to render the same fallbacks with generic text instead.
|
|
207
|
+
|
|
208
|
+
This setting is honored by the `Weft::Defaults` classes. If you assign custom fallback targets, honoring it is up to your implementations — check `Weft.configuration.verbose_error_pages` where you'd reveal detail.
|
|
209
|
+
|
|
210
|
+
### `htmx_errors`
|
|
211
|
+
|
|
212
|
+
Default: `:fragment`.
|
|
213
|
+
|
|
214
|
+
How errors present when the failing request came from htmx *and* the error fell through to a gem-default fallback target:
|
|
215
|
+
|
|
216
|
+
- `:fragment` — swap the error rendering into the failing element's place in the page. The error is visible exactly where the problem is; the rest of the page keeps working.
|
|
217
|
+
- `:redirect` — send the client to the error page instead (via an `HX-Redirect` header), abandoning the current page. Some apps prefer a full-page failure posture over patchwork error states.
|
|
218
|
+
|
|
219
|
+
Two carve-outs to know about: explicit `recovers` targets you declare are never overridden — this setting only governs the gem-default fallthrough — and `Weft::NotFound` is exempt, so a missing record renders as an in-place not-found fragment even under `:redirect`.
|