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
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.