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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: fada6be56bfe906aa7c6f8d30f2e759b750517440027eada9b98b3afcff9e4d9
|
|
4
|
+
data.tar.gz: bd66b0c5d17e658de87ebc058ca393a5d94632b1cb5251c5f43fb9a757d35656
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 376016a9b727cf23334ca98410778b3c90f4aa2d38a1cffe84ac91b4b32e82d2afb148a84bc370309962406c536e2dc458c3f0310ea2020cfb40b75a2ba5e657
|
|
7
|
+
data.tar.gz: f9c26e0e7514cb92356ed4a78b515f2f367dcb92126c259318d14378469e2d26ffe9af1ee64440f671a18239cfdb10312942c12bec862518a2dbe63446c30b39
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.1.0 (2026-07-12)
|
|
4
|
+
|
|
5
|
+
First usable release. Weft is component-oriented hypermedia for Ruby: components declare their structure, their data, and their interactive behaviors, and the framework derives the routing, request handling, and client-side wiring automatically.
|
|
6
|
+
|
|
7
|
+
### New Features:
|
|
8
|
+
|
|
9
|
+
- **Components and pages** – Subclass `Weft::Component` and `Weft::Page`, declare `attribute`s, and build HTML in Ruby via Arbre:
|
|
10
|
+
- Components render standalone fragments or compose into pages through generated builder methods (`builder_method :name`)
|
|
11
|
+
- Declared attributes resolve from request parameters with defaults and type coercion, and reach your code as `attrs.whatever`
|
|
12
|
+
- Pages carry the document shell: title, stylesheets, scripts, inline CSS, all inheritable down the page hierarchy
|
|
13
|
+
- **The verb DSL** – One-line declarations for dynamic behavior:
|
|
14
|
+
- `performs :name` – user-initiated actions: run your callable, re-render the component
|
|
15
|
+
- `transfers :name, to: Other` – actions that render a different component in the caller's place
|
|
16
|
+
- `dismisses :name` – actions that remove the component from the DOM
|
|
17
|
+
- `refreshes every:` / `refreshes on:` – client re-fetches on a timer (whole or fractional seconds, down to a 1ms floor) or when a page event fires
|
|
18
|
+
- `pushes every:` – the server streams re-renders over SSE, with an immediate first frame for new subscribers
|
|
19
|
+
- `triggers "event"` – announce action responses to the rest of the page
|
|
20
|
+
- `includes Other` – companion components ride along out-of-band in action responses
|
|
21
|
+
- `recovers from:, with:` – declarative error behavior per class
|
|
22
|
+
- **Auto-routing** – Components route at `/_components/<name>` and pages at name-derived paths, with the conventional class-name suffix stripped (`OrdersPanelComponent`, `OrdersPanel`, and `DashboardPage`, `Dashboard` all route without ceremony):
|
|
23
|
+
- Explicit overrides via `self.page_path` and `self.component_path =`; global knobs for the component prefix and stream suffix
|
|
24
|
+
- Routability inferred from declared state, with `abstract!` / `routable!` as escape hatches
|
|
25
|
+
- SSE stream endpoints generated automatically for every pushing component
|
|
26
|
+
- **Route collision detection** – Two routable classes resolving to the same effective path raise `Weft::InvalidDefinition` naming both, lazily on the first request. Code reloaders that redefine a class prune the stale registration automatically; `Weft.registry.clear` gives reload integrations and tests a full reset
|
|
27
|
+
- **Element kwargs** – `action:`, `loads:`, `trigger:`, `navigate:`, and `push_url:` work on any element at any nesting depth, with `target:` and `swap:` refinements on `loads:` and the shorthands. Raw htmx attributes pass through untouched, side by side with what the kwargs expand to
|
|
28
|
+
- **Interaction shorthands** – `tooltip:`, `modal:`, `lazy:`, `load_more:`, `infinite_scroll:`, `live_search:`, `tabs:`, `inline_expand:`, and `retry:` — named presets over the `loads:` machinery with the trigger and swap details baked in. Register your own vocabulary with `Weft.register_shorthand`
|
|
29
|
+
- **Error handling** – A semantic error family under `Weft::Error` (`InvalidConfiguration`, `InvalidDefinition`, `InvalidUsage`, and the `HTTPError` classes such as `Weft::NotFound` and `Weft::Unprocessable`):
|
|
30
|
+
- The `recovers` chain renders declared fallbacks with semantic status codes (a validation failure is a `422` whose body is the component wearing its error state)
|
|
31
|
+
- Recovery targets receive schema-gated context — `:exception`, `:request_path`, `:status_code`, `:component_id`, `:retry_url` — only where declared
|
|
32
|
+
- Brand the defaults app-wide via the `error_component` / `error_page` / `not_found_page` / `not_found_component` knobs, or per class with explicit `recovers` declarations
|
|
33
|
+
- The gem-default error components offer one-click retry via the `retry:` shorthand
|
|
34
|
+
- **Configuration** – `Weft.configure` covers development reloading (`auto_reload`, `reload_paths`), logging (`Weft.logger`, stdout by default; `log_level`, `router_logging`), static asset bundles (`static_assets` with named bundles, path-containment checks, and `assets:` resolution on `register_stylesheet` / `register_script`), htmx delivery (`include_htmx`, `include_sse_ext`), and routing (`component_path`, `stream_suffix`)
|
|
35
|
+
- **Security posture** – The htmx core and SSE-extension scripts Weft includes are subresource-integrity pinned; `register_script` forwards `integrity:` / `crossorigin:` (and any other attributes) to the tag for your own CDN scripts
|
|
36
|
+
- **Documentation** – A complete set under `docs/`: a build-your-first-app tutorial; references for the DSL, routing, error handling, configuration, and the Arbre HTML layer; an application-patterns guide (service objects, databases, background jobs, authentication, CSRF, testing); and a twenty-one-page examples catalog with captured wire traffic that deliberately covers the ground of htmx's own examples
|
|
37
|
+
- **Demo app** – A complete Sinatra + Weft application under `demo/`, exercising the feature surface end to end
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andy Rusterholz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Weft
|
|
2
|
+
|
|
3
|
+
**Component-oriented hypermedia for Ruby.**
|
|
4
|
+
|
|
5
|
+
Weft lets you write your application in terms of its interface: components declare their structure, their data, and their interactive behaviors, and the framework derives the routing, request handling, and client-side wiring automatically.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
class DeliveryStatus < Weft::Component
|
|
9
|
+
attribute :delivery_id
|
|
10
|
+
|
|
11
|
+
refreshes every: 5.seconds
|
|
12
|
+
|
|
13
|
+
performs :cancel do |attrs|
|
|
14
|
+
delivery = Delivery.find(attrs.delivery_id)
|
|
15
|
+
CancelDelivery.call(delivery)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def build(attributes = {})
|
|
19
|
+
super
|
|
20
|
+
delivery = Delivery.find(attrs.delivery_id)
|
|
21
|
+
div(class: "delivery-status") do
|
|
22
|
+
progress value: delivery.progress, max: 100
|
|
23
|
+
button "Cancel", action: :cancel if delivery.cancelable?
|
|
24
|
+
span "Arriving #{delivery.eta}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That's a complete, interactive UI component. It polls for updates every 5 seconds. The cancel button invokes a service and re-renders the result. There's no routes file, no controller, no custom JavaScript — just Ruby describing what the UI is and what it does. The UI is the source of truth; the plumbing is implied.
|
|
31
|
+
|
|
32
|
+
Weft is built on [Arbre](https://github.com/activeadmin/arbre) for HTML generation and [htmx](https://htmx.org) for hypermedia interactions. It runs standalone as a lightweight Sinatra-backed server, or mounts as middleware inside any existing Rack app. No build step, no npm, no hydration — just Ruby, HTML, and HTTP.
|
|
33
|
+
|
|
34
|
+
### The verbs
|
|
35
|
+
|
|
36
|
+
Components declare their dynamic behaviors with verbs:
|
|
37
|
+
|
|
38
|
+
| Verb | What it does |
|
|
39
|
+
|------|-------------|
|
|
40
|
+
| `refreshes every: 5.seconds` | Client re-fetches on a timer |
|
|
41
|
+
| `refreshes on: "event"` | Client re-fetches when a page event fires |
|
|
42
|
+
| `pushes every: 5.seconds` | Server streams re-renders over SSE |
|
|
43
|
+
| `performs :name` | User-initiated action: runs your callable, re-renders |
|
|
44
|
+
| `transfers :name, to: Other` | Action that renders a different component in this one's place |
|
|
45
|
+
| `dismisses :name` | Action that removes the component from the DOM |
|
|
46
|
+
| `triggers "event"` | Announces this component's actions to the rest of the page |
|
|
47
|
+
| `includes Other` | Companion components ride along in action responses, out-of-band |
|
|
48
|
+
| `recovers from: Err, with: Fallback` | Declares what renders when something raises |
|
|
49
|
+
|
|
50
|
+
Elements get their own vocabulary — `action:`, `loads:`, `trigger:` kwargs and interaction shorthands like `tooltip:`, `modal:`, `lazy:`, `infinite_scroll:` — all covered in [the DSL reference](docs/dsl.md).
|
|
51
|
+
|
|
52
|
+
## Documentation
|
|
53
|
+
|
|
54
|
+
- **[Build your first Weft app](docs/tutorial.md)** — the tutorial: empty directory to a working app with pages, components, a validated form action, and live updates.
|
|
55
|
+
- **[Examples](docs/examples/README.md)** — twenty-one worked patterns with captured wire traffic. Coming from htmx? This catalog deliberately covers the ground of htmx's own examples.
|
|
56
|
+
- **[The Weft DSL](docs/dsl.md)** — every verb, element kwarg, and interaction shorthand.
|
|
57
|
+
- **[Application patterns](docs/app-patterns.md)** — the app around the components: service objects, databases, background jobs, authentication, CSRF, assets, and testing.
|
|
58
|
+
- **[Arbre: the HTML layer](docs/arbre.md)** — the HTML builder inside every `build` method, in depth.
|
|
59
|
+
- **[Routing](docs/routing.md)** — how classes become URLs, what's routable, and collision detection.
|
|
60
|
+
- **[Error handling](docs/error-handling.md)** — the error classes, recovery chains, and branding your error pages.
|
|
61
|
+
- **[Configuration](docs/configuration.md)** — every setting.
|
|
62
|
+
|
|
63
|
+
## Roadmap & Availability
|
|
64
|
+
|
|
65
|
+
| Version | Features | Status |
|
|
66
|
+
|---------|---------|--------|
|
|
67
|
+
| v0.1.0 | First usable release: the verb DSL, auto-routing with collision detection, interaction shorthands, SSE, error recovery, full documentation set | **Current** |
|
|
68
|
+
| v0.2 | Attribute hydration (resolver reification), child-component ergonomics, SSE error recovery, Zeitwerk integration | Next |
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
Weft requires Ruby 3.2 or newer. Add it to your Gemfile:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
gem "weft"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Then run:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bundle install
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
Weft mounts into your Rack app in one of two shapes, depending on whether Weft is the entire application or just a part of one.
|
|
87
|
+
|
|
88
|
+
### Standalone — Weft is the app
|
|
89
|
+
|
|
90
|
+
For a fully Weft-powered application, run `Weft::Router` as the Rack app itself:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
# config.ru
|
|
94
|
+
require_relative "config/environment" # loads your components and pages
|
|
95
|
+
|
|
96
|
+
run Weft::Router
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Components and pages auto-route based on their class declarations: components serve HTML fragments under `/_components/<name>`, pages serve full documents at their `page_path` (or a name-derived default). If two routable classes would resolve to the same path, Weft raises on the first request, naming both. [Routing](docs/routing.md) has the full story, and [the tutorial](docs/tutorial.md) walks through a working `config/environment.rb`.
|
|
100
|
+
|
|
101
|
+
### As middleware — alongside an existing app
|
|
102
|
+
|
|
103
|
+
For adding Weft to an existing Rack app (Sinatra, Rails, anything Rack), mount it as middleware. Unmatched paths fall through to your downstream app:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# config.ru
|
|
107
|
+
require_relative "config/environment"
|
|
108
|
+
require_relative "app" # your existing application
|
|
109
|
+
|
|
110
|
+
use Weft::Router
|
|
111
|
+
run MyApp
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Configuration
|
|
115
|
+
|
|
116
|
+
`Weft.configure` exposes gem-level settings — the two you'll want on day one are the development reloader flags:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
Weft.configure do |c|
|
|
120
|
+
c.auto_reload = (ENV.fetch("RACK_ENV", "production") == "development")
|
|
121
|
+
c.reload_paths = [File.expand_path("app/**/*.rb", __dir__)]
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Everything else — static asset bundles, error presentation, routing overrides, logging — is in [the configuration reference](docs/configuration.md).
|
|
126
|
+
|
|
127
|
+
### Customizing error and not-found pages
|
|
128
|
+
|
|
129
|
+
Assign your own fallback classes once, and every recovery path uses them:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
Weft.configure do |c|
|
|
133
|
+
c.error_component = MyApp::ErrorComponent
|
|
134
|
+
c.not_found_page = MyApp::NotFoundPage
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Per-class `recovers` declarations override the app-wide fallbacks where you need finer grain. [Error handling](docs/error-handling.md) covers the error classes, the recovery chain, and the attributes your fallback pages can receive.
|
|
139
|
+
|
|
140
|
+
## Contributing
|
|
141
|
+
|
|
142
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/rusterholz/weft. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](CODE_OF_CONDUCT.md).
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
147
|
+
|
|
148
|
+
## Code of Conduct
|
|
149
|
+
|
|
150
|
+
Everyone interacting in the Weft project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# Application patterns
|
|
2
|
+
|
|
3
|
+
The [tutorial](tutorial.md) gets an app running, and the reference docs cover each mechanism on its own. This page is about the application *around* the components — where things live as a Weft app grows, and how the rest of your stack plugs in.
|
|
4
|
+
|
|
5
|
+
The theme throughout: Weft is deliberately small. It owns pages, components, and the wiring between user actions and renders. Everything else — your data, your background jobs, your authentication, your assets — is ordinary Ruby on ordinary Rack, and each has a well-defined seam.
|
|
6
|
+
|
|
7
|
+
## Laying out a growing app
|
|
8
|
+
|
|
9
|
+
The tutorial's layout extends naturally:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
my-app/
|
|
13
|
+
├── config.ru
|
|
14
|
+
├── config/
|
|
15
|
+
│ └── environment.rb
|
|
16
|
+
├── app/
|
|
17
|
+
│ ├── components/
|
|
18
|
+
│ ├── data/ # stores, models, Current — your data layer
|
|
19
|
+
│ ├── pages/
|
|
20
|
+
│ └── services/ # business operations
|
|
21
|
+
└── spec/
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`config/environment.rb` grows one entry in its load list:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
%w[data services components pages].each do |dir|
|
|
28
|
+
Dir[File.join(APP_ROOT, "app", dir, "*.rb")].sort.each { |file| require file }
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The ordering carries the same rule the tutorial explains: files that *define* things load before files that *reference* them in class bodies (`includes AttendeeList`, `transfers to: Confirmation`). Data and services never reference components, components reference each other and the data layer, pages compose components — so data → services → components → pages loads cleanly. When alphabetical luck within a directory stops being enough, that's the sign your app has outgrown the glob and wants a real autoloader like Zeitwerk.
|
|
33
|
+
|
|
34
|
+
## Where business logic goes
|
|
35
|
+
|
|
36
|
+
Components own what the user **sees and does** — the markup, the affordances, and the immediate response to an action. They should not own your business rules.
|
|
37
|
+
|
|
38
|
+
The dividing line runs through the action callable. A callable is a translation layer: attributes in, one operation invoked, a hash out for the re-render. The moment the middle step grows past a few lines — multiple records, a transaction, an email, a job — it belongs in a plain Ruby service object:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# app/services/comment_poster.rb
|
|
42
|
+
class CommentPoster
|
|
43
|
+
def self.call(author:, body:)
|
|
44
|
+
author = author.to_s.strip
|
|
45
|
+
body = body.to_s.strip
|
|
46
|
+
return :blank if author.empty? || body.empty?
|
|
47
|
+
|
|
48
|
+
GUEST_COMMENTS << { author: author, body: body }
|
|
49
|
+
:posted
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# in the component
|
|
56
|
+
performs :post do |attrs|
|
|
57
|
+
CommentPoster.call(author: attrs.author, body: attrs.body)
|
|
58
|
+
{ author: nil, body: nil }
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The service knows nothing about components or HTML; the component knows nothing about how a post actually happens. Two details worth noticing:
|
|
63
|
+
|
|
64
|
+
- **End the callable with the hash you mean.** A callable's return value merges into the attributes for the re-render *if it's a hash* — anything else is discarded (see [the callable contract](dsl.md#the-callable-contract)). Delegating to a service and then returning your own hash, as above, keeps the wire state deliberate even when the service's return value changes.
|
|
65
|
+
- **The service returns plain Ruby values** (`:posted`, `:blank`, a record, a result object — whatever fits). When the component needs to branch on the outcome, branch in the callable and translate to attributes; the service still shouldn't know what a DOM id is.
|
|
66
|
+
|
|
67
|
+
## Databases
|
|
68
|
+
|
|
69
|
+
Weft has no data layer and no ORM opinion. `build` methods and callables are plain Ruby, so `Event.find(...)`, `DB[:events].where(...)`, and a hand-rolled store are all the same to Weft — the examples' data-constant stubs stand in for whichever you choose. ActiveRecord in standalone mode, Sequel, ROM, or a bare adapter are all equally at home; establish the connection in `config/environment.rb` before the app files load, and your components can use it from the first render.
|
|
70
|
+
|
|
71
|
+
One operational honesty about the in-memory stores the tutorial and examples use: module-level state is **per-process**. That's exactly right for learning and development, and exactly wrong under a multi-process server — each Puma worker gets its own copy, and they drift immediately. The moment state needs to be shared or survive a restart, it belongs in a real database (or Redis, or any store that lives outside the app process).
|
|
72
|
+
|
|
73
|
+
## Background jobs
|
|
74
|
+
|
|
75
|
+
Weft doesn't run jobs, but it has a natural shape for showing their progress: **a job writes to the store; a component watches the store**.
|
|
76
|
+
|
|
77
|
+
The pattern in three steps:
|
|
78
|
+
|
|
79
|
+
1. **The action dispatches and returns.** A user action that starts long work shouldn't wait for it — the callable enqueues the job (Sidekiq, SolidQueue, GoodJob, a `Thread` in development — Weft doesn't care) and returns immediately with whatever attributes render the "started" state.
|
|
80
|
+
2. **The job writes progress to the shared store** as it works: a status column, a percentage, a result row. The job knows nothing about components.
|
|
81
|
+
3. **The component re-renders on a cadence** — [`refreshes every:`](dsl.md#refreshes--the-client-re-fetches) polls, [`pushes every:`](dsl.md#pushes--the-server-sends-updates) streams over SSE — and each render just reads the store. Completion isn't an event to handle; it's data the next render picks up.
|
|
82
|
+
|
|
83
|
+
The [Progress Bar](examples/progress-bar.md) example is this exact lifecycle, verified end to end — a Start action kicking off a background worker, a polling component advancing as the store changes, a finished state with a Restart affordance. [Live Ticker](examples/live-ticker.md) is the SSE variant. Both pages show precisely what travels on the wire.
|
|
84
|
+
|
|
85
|
+
## Authentication and sessions
|
|
86
|
+
|
|
87
|
+
Weft is deliberately session-agnostic: no cookie handling, no `current_user`, no login machinery. Identity is your app's concern, handled with standard Rack pieces in front of the Router. What Weft *does* define is the seam, and it's narrower than you might expect:
|
|
88
|
+
|
|
89
|
+
> Components and callables receive exactly their **resolved attributes** — values from request parameters, filtered through each component's declared schema. Session state and request headers are not part of that channel.
|
|
90
|
+
|
|
91
|
+
So per-request identity needs its own channel. The pattern that fits — the same one Rails blesses as `Current` — is a [`CurrentAttributes`](https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html) object set by middleware. Weft already depends on ActiveSupport, so it's available without adding anything:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
# app/data/current.rb
|
|
95
|
+
require "active_support"
|
|
96
|
+
require "active_support/current_attributes"
|
|
97
|
+
|
|
98
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
99
|
+
attribute :user, :csrf_token
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
(The bare `require "active_support"` line matters: requiring only `current_attributes` fails on recent ActiveSupport versions, which expect the framework's base to be loaded first.)
|
|
104
|
+
|
|
105
|
+
A small middleware reads the session once per request, exposes it through `Current`, and — importantly — always resets afterwards, so state never leaks between requests on a reused thread:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class CurrentScope
|
|
109
|
+
def initialize(app)
|
|
110
|
+
@app = app
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def call(env)
|
|
114
|
+
session = env["rack.session"]
|
|
115
|
+
Current.user = session["user"]
|
|
116
|
+
Current.csrf_token = Rack::Protection::AuthenticityToken.token(session)
|
|
117
|
+
@app.call(env)
|
|
118
|
+
ensure
|
|
119
|
+
Current.reset
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Session *writes* — logging in and out — live outside Weft components, in any Rack endpoint you own. A tiny Sinatra app is plenty, mounted beside the Router:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
class AuthApp < Sinatra::Base
|
|
128
|
+
post "/login" do
|
|
129
|
+
session["user"] = params["name"]
|
|
130
|
+
redirect "/", 303
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
post "/logout" do
|
|
134
|
+
session.delete("user")
|
|
135
|
+
redirect "/", 303
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`config.ru` assembles the stack — session first, then protection, then `CurrentScope`, then the apps:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
require_relative "config/environment"
|
|
144
|
+
require "rack/session"
|
|
145
|
+
require "rack/protection"
|
|
146
|
+
require "sinatra/base"
|
|
147
|
+
|
|
148
|
+
use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET") # 64+ characters
|
|
149
|
+
use Rack::Protection::AuthenticityToken
|
|
150
|
+
use CurrentScope
|
|
151
|
+
|
|
152
|
+
map "/auth" do
|
|
153
|
+
run AuthApp
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
run Weft::Router
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
From there, any component can read identity like any other Ruby value:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
class WhoAmI < Weft::Component
|
|
163
|
+
builder_method :who_am_i
|
|
164
|
+
|
|
165
|
+
def build(attributes = {})
|
|
166
|
+
super
|
|
167
|
+
if Current.user
|
|
168
|
+
para "Signed in as #{Current.user}."
|
|
169
|
+
form(action: "/auth/logout", method: "post") do
|
|
170
|
+
input type: "hidden", name: "authenticity_token", value: Current.csrf_token
|
|
171
|
+
input type: "submit", value: "Sign out"
|
|
172
|
+
end
|
|
173
|
+
else
|
|
174
|
+
para "You're browsing anonymously."
|
|
175
|
+
form(action: "/auth/login", method: "post") do
|
|
176
|
+
input type: "hidden", name: "authenticity_token", value: Current.csrf_token
|
|
177
|
+
label "Name ", for: "name"
|
|
178
|
+
input type: "text", name: "name", id: "name"
|
|
179
|
+
input type: "submit", value: "Sign in"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Note the form lines: `action:` with a **string** renders as a plain HTML `action` attribute — an ordinary full-page form post to your auth endpoint, no htmx involved. It's only `action:` with a **symbol** that wires up a Weft component action. The two coexist naturally: identity changes full pages; component actions swap fragments.
|
|
187
|
+
|
|
188
|
+
(This example stores the user's name in the session directly to stay small; a real app stores an id and has `CurrentScope` — or a lazy reader on `Current` — hydrate the user record.)
|
|
189
|
+
|
|
190
|
+
Authorization follows the same grain: middleware can gate whole path prefixes before Weft ever sees the request, and components can branch on `Current.user` to decide which affordances to render. For anything destructive, check authority *in the action's service call* too — component actions are plain HTTP endpoints, and markup you didn't render is not a guarantee nobody sends the request.
|
|
191
|
+
|
|
192
|
+
## CSRF protection
|
|
193
|
+
|
|
194
|
+
The stack above already includes it. `Rack::Protection::AuthenticityToken` (part of rack-protection, which ships with Sinatra) rejects any non-GET request that doesn't carry a valid token — **including every Weft action form**, since those are ordinary POSTs. Two pieces make it work:
|
|
195
|
+
|
|
196
|
+
1. `CurrentScope` computes the session's token via `Rack::Protection::AuthenticityToken.token(session)` and exposes it as `Current.csrf_token`.
|
|
197
|
+
2. Every form gains one hidden input:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
input type: "hidden", name: "authenticity_token", value: Current.csrf_token
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
That covers both transports at once: the htmx path (a form's fields are its payload) and the no-JS fallback submit carry the same field. A missing or stale token is a `403` before the request reaches any component; with the token, actions behave exactly as before. Weft has no form helper to inject this automatically today, so the hidden input is per-form — a base component class of your own is a fine place to fold it into a one-liner.
|
|
204
|
+
|
|
205
|
+
## Assets
|
|
206
|
+
|
|
207
|
+
The seam is [`static_assets`](configuration.md#static_assets): register a directory as a named bundle, and `register_stylesheet` / `register_script` resolve bare-relative paths against it. Whatever produces the files — esbuild, Vite, Tailwind's CLI, Sprockets, or a `cp` in your Makefile — Weft only needs to know the output directory:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
Weft.configure do |c|
|
|
211
|
+
c.static_assets root: "/assets", from: File.join(APP_ROOT, "public/assets")
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Point your bundler's output at that directory and the integration is done. The [configuration reference](configuration.md#static_assets) covers multiple bundles, environment-specific roots, and the resolution rules.
|
|
216
|
+
|
|
217
|
+
## Testing the whole app
|
|
218
|
+
|
|
219
|
+
[Component unit testing](arbre.md#testing-components) needs no server: `Component.render(**attrs)` returns the HTML string. For request-level coverage — routes, actions, status codes, wire payloads — use Rack::Test against the Router:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# spec/spec_helper.rb
|
|
223
|
+
ENV["RACK_ENV"] ||= "test"
|
|
224
|
+
|
|
225
|
+
require "rack/test"
|
|
226
|
+
require_relative "../config/environment"
|
|
227
|
+
|
|
228
|
+
RSpec.configure do |config|
|
|
229
|
+
config.before(:each) do
|
|
230
|
+
GUEST_COMMENTS.replace([{ author: "Rosa", body: "Lovely event — count me in for next year." }])
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# spec/router_spec.rb
|
|
237
|
+
RSpec.describe "the app through Weft::Router" do
|
|
238
|
+
include Rack::Test::Methods
|
|
239
|
+
|
|
240
|
+
def app = Weft::Router
|
|
241
|
+
|
|
242
|
+
it "serves a component fragment" do
|
|
243
|
+
get "/_components/comment_section"
|
|
244
|
+
expect(last_response.status).to eq(200)
|
|
245
|
+
expect(last_response.body).to include("Rosa")
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
it "runs an action" do
|
|
249
|
+
post "/_components/comment_section/post", author: "Test", body: "From a spec."
|
|
250
|
+
expect(last_response.status).to eq(200)
|
|
251
|
+
expect(last_response.body).to include("Test")
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Two lines in that helper do quiet, load-bearing work:
|
|
257
|
+
|
|
258
|
+
- **`ENV["RACK_ENV"] ||= "test"`.** Without it, specs run in Sinatra's development mode, whose DNS-rebinding host protection rejects Rack::Test's default host — every request comes back `403 Host not permitted` before reaching your app. The test environment permits it.
|
|
259
|
+
- **The `before(:each)` reset.** Weft doesn't own your data layer, so state between examples is your responsibility — replace the in-memory store's contents, truncate tables, or run each example in a transaction, per your data layer's usual practice.
|
|
260
|
+
|
|
261
|
+
These specs hit `Weft::Router` directly, bypassing whatever middleware `config.ru` stacks in front — which is usually what you want when the subject is component behavior. (It's also why the action POST above needs no CSRF token: the protection middleware isn't in the loop.)
|
|
262
|
+
|
|
263
|
+
## Where to go from here
|
|
264
|
+
|
|
265
|
+
- [The examples catalog](examples/README.md) — worked patterns with captured wire traffic, including the [Progress Bar](examples/progress-bar.md) job lifecycle.
|
|
266
|
+
- [The Weft DSL](dsl.md) — the callable contract, every verb, every kwarg.
|
|
267
|
+
- [Routing](routing.md#mounting-the-router) — standalone vs. middleware mounting in detail.
|
|
268
|
+
- [Configuration](configuration.md) — static assets, reloading, error presentation.
|