Skip to content
rusterholzPublic

About

Component-oriented hypermedia for Ruby

Topics

Resources

Code of conduct

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Weft

Component-oriented hypermedia for Ruby.

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.

class DeliveryStatus < Weft::Component
  param :delivery_id, type: :integer

  derives(:delivery) { |params| Delivery.find(params.delivery_id) }

  performs(:cancel) { |params| CancelDelivery.call(params.delivery) }

  refreshes every: 5.seconds

  def build(attributes = {})
    super
    add_class "delivery-status"

    span "Delivery ##{params.delivery_id}"
    div(class: "delivery-detail") do
      progress_bar value: params.delivery.progress_percent, max: 100
      span "Arriving #{params.delivery.eta}"
      button "Cancel", action: :cancel if params.delivery.cancellable?
    end
  end
end

That's a complete, interactive UI component. The cancel button invokes a service and re-renders the result; the card polls for fresh state every 5 seconds. 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.

Here is everything that renders, htmx wiring and all:

<div id="delivery-status-4471" hx-get="/_components/delivery_status?delivery_id=4471"
     hx-trigger="every 5s" hx-swap="outerHTML" class="delivery-status">
  <span>Delivery #4471</span>
  <div class="delivery-detail">
    <div id="progress-bar" class="progress">
      <div class="progress-fill" style="width: 62%"></div>
    </div>
    <span>Arriving today, 4:15 PM</span>
    <button hx-post="/_components/delivery_status/cancel" hx-target="#delivery-status-4471"
            hx-swap="outerHTML" hx-vals="{&quot;delivery_id&quot;:4471}">Cancel</button>
  </div>
</div>

Every attribute above was derived from those four declarations: the routes (GET /_components/delivery_status for the component, POST /_components/delivery_status/cancel for the action), the DOM id that keeps this delivery individually addressable, the polling on the wrapper, and the button's whole request: where the response lands, how it swaps, and which params ride along with it. progress_bar is a child component with declarations of its own, rendered inline, wrapper and all.

Weft is built on Arbre for HTML generation and htmx 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.

Component Vocabulary

Components declare their dynamic behaviors with a DSL. Here are some broader examples, showing some plausible components with everything they declare on show:

# LIVE - a tile that keeps itself current, and tells the page when it changes:
class ShipmentTile < Weft::Component
  param :shipment_id                                 # wire state: rides the URL
  derives(:shipment) { |p| Shipment.find(p.shipment_id) }   # looked up on demand, once

  pushes every: 10.seconds                           # server streams re-renders over SSE
  announces "shipment-moved"                          # ...and announces them to the page
  recovers from: Carrier::Timeout,                   # a flaky feed degrades; it doesn't crash
           with: StaleShipmentTile

  performs :expedite do |params|                     # your callable runs, then it re-renders
    Shipping::Expedite.call(params.shipment)         # the same record the render below shows
  end

  def build(attributes = {})
    super
    h3 params.shipment.tracking_number
    span params.shipment.status
    button "Expedite", action: :expedite
  end
end

# INTERACTIVE - a row that becomes its own edit form, and hands the slot back on save:
class OrderRow < Weft::Component
  param :order_id                                    # its own id, so it can act alone
  receives :order                                    # the record the table already loaded

  transfers :edit, to: EditableOrderRow              # give this DOM slot to the editor
  dismisses :cancel                                  # ...or take the row off the page
  announces "order-changed", on: :cancel              # that one action makes news

  def build(attributes = {})
    super
    td params.order.number
    td do
      button "Edit",   action: :edit
      button "Cancel", action: :cancel, confirm: "Cancel this order?"
    end
  end
end

class EditableOrderRow < Weft::Component
  param :order_id
  derives(:order) { |p| Order.find(p.order_id) }

  transfers :save, to: OrderRow do |params|          # save, then hand the DOM slot back
    Orders::Update.call(params.order, params.to_h)
  end
  brings OrderTotalsCard, on: :save                # the totals card rides back too

  def build(attributes = {})
    super
    td { input name: "number", value: params.order.number }
    td { button "Save", action: :save }
  end
end

# LAZY LOADING - a feed nobody pays for until it is wanted:
class ActivityFeed < Weft::Component
  param :page, default: 1, type: :integer            # coerced off the query string
  defines heading: "Recent activity"                 # a fixed value a subclass can pin

  refreshes on: "order-changed"                      # the other half of that announcement

  def build(attributes = {})
    super
    h2 params.heading
    div lazy: RecentEventsList                       # fetched when it scrolls into view
    button "Older", load_more: ActivityFeed, with: { page: params.page + 1 }
  end
end

These four classes use all of the DSL verbs, but only two of the interaction presets (lazy and load_more). The DSL reference has the complete set for your perusal, and the Examples contain twenty-one worked patterns along with the wire traffic each one produces.

Why Weft, and When

Weft starts from one question: what if the whole app were described as components? A weft component declares its params, its actions, and how it stays current, and weft derives the rest: the routes, the DOM ids, and the htmx attributes you'd otherwise write by hand. There are no controllers or routes files to keep in step, and an interactive component answers at its own URL, so unless it relies on objects its parent hands it, you can open it, refresh it, or test it on its own. That suits an interface made of many small, live pieces that each know how to update themselves.

Each of the usual alternatives shares some of that ground:

  • Hotwire (Turbo and Stimulus) shares weft's bet on HTML over the wire, and it's the Rails default: in a new Rails app, it's already there. You keep writing the routes, controllers and templates, and Turbo swaps in what they return; weft derives that plumbing from the component instead. Stimulus also gives behavior that never needs the server, like an instant toggle, a home of its own. In weft that's still yours to write, as it would be with htmx alone.
  • ViewComponent shares weft's idea that views should be reusable, testable classes. It's mature, stable, feature-complete by its own description, and the established choice in Rails. But a ViewComponent only renders: the routes, controller actions and Turbo or Stimulus wiring that make it interactive live somewhere else, where a weft component declares its own.
  • Phlex shares weft's choice of plain Ruby classes over templates, works with or without Rails, and reads more like a weft component than anything else here. (Weft writes its HTML with Arbre, the builder behind ActiveAdmin's views.) But Phlex leaves interactivity to whatever you pair it with, and weft builds it into the component.
  • ReActionView shares weft's aim of live pages without a client app, starting from the ERB templates you already have: if your app lives in ERB, it meets you there. It also gives the browser state of its own, which weft doesn't. Weft's state rides each component's params, and there are no templates or controllers to answer from: the component is the whole unit.
  • React, often with Apollo Federation once several teams share an app, has the deepest ecosystem there is for rich client-side behavior, and Apollo composes each team's data graph behind a central router. Weft keeps the UI on the server with no JavaScript build, and it's headed toward federation without the router: one weft app rendering another's components in its own pages, with the goal that each app need only know the others exist.

Today's Caveats

  • Weft is 0.x, so a minor release may carry breaking changes. The CHANGELOG lists every one.
  • Weft components can't yet render inside Rails views. Today weft runs on Sinatra, standalone or as Rack middleware beside an existing app, Rails included; a companion gem for Rails comes next.
  • Federation is a direction, not a feature yet.
  • The ecosystem is just starting to grow: the Examples cover twenty-one patterns, but there's no component library to install yet.

Documentation

  • Build your first Weft app -- the tutorial: empty directory to a working app with pages, components, a validated form action, and live updates.
  • Examples -- twenty-one worked patterns with captured wire traffic. Coming from htmx? This catalog covers the ground of htmx's own examples.
  • The Weft DSL -- every verb, element kwarg, and interaction preset.
  • How Params Flow -- the data lifecycle: a request comes in, each component pulls what it needs through param/receives/derives/defines, and renders with enough of its own wire state to refresh or act on its own.
  • The Request -- what a component can read about the request it renders for: headers, htmx's headers, cookies, the client's address, and an id every response Weft answers carries back.
  • Application Patterns -- the app around the components: service objects, databases, background jobs, authentication, CSRF, assets, and testing.
  • Arbre: the HTML layer -- the HTML builder inside every build method, in depth.
  • Routing -- how classes become URLs, what's routable, and collision detection.
  • Error Handling -- the error classes, recovery chains, and branding your error pages.
  • Configuration -- every setting.

Roadmap & Availability

Version Features Status
v0.1.0 First usable release: the verb DSL, auto-routing with collision detection, interaction presets, SSE, error recovery, full documentation set Shipped
v0.2.0 The inputs model: four declared doors into params, typed wire params, values flowing down the render tree, one-call app loading, self-healing streams, brandable 404s Current
v0.3 The request–response lifecycle: how a request is addressed, carried, observed, and composed into a response Next

Installation

Weft requires Ruby 3.2 or newer. Add it to your Gemfile:

gem "weft"

Then run:

bundle install

Usage

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.

Standalone (Weft is the app)

For a fully Weft-powered application, run Weft::Router as the Rack app itself:

# config.ru
require_relative "config/environment"   # loads your components and pages

run Weft::Router

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 has the full story, and the tutorial walks through a working config/environment.rb.

As Middleware (alongside an existing app)

For adding Weft to an existing Rack app (Sinatra, Rails, anything Rack), mount it as middleware. Unmatched paths fall through to your downstream app:

# config.ru
require_relative "config/environment"
require_relative "app"   # your existing application

use Weft::Router
run MyExistingApp

Configuration

The knob you'll likely want to know about on day one is Weft.configure_autoloading. This puts Zeitwerk in charge of loading your app's directories, and with reload: true your edits (including new files and deletions) will apply without restarting the server:

Weft.configure_autoloading(
  paths: [File.expand_path("app/components", __dir__),
          File.expand_path("app/pages", __dir__)],
  reload: ENV.fetch("RACK_ENV", "production") == "development"
)

Other gem-level settings (static asset bundles, error presentation, routing overrides, logging, more) are set through Weft.configure. See the configuration reference.

Customizing error and not-found pages

Assign your own fallback classes once, and every recovery path uses them:

Weft.configure do |c|
  c.error_component = MyApp::ErrorComponent
  c.not_found_page = MyApp::NotFoundPage
end

Per-class recovers declarations override the app-wide fallbacks where you need finer grain. Error handling covers the error classes, the recovery chain, and the attributes your fallback pages can receive.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/rusterholz/weft. The development guide covers setup, the test suites, and the release process. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Code of Conduct

Everyone interacting in the Weft project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

About

Component-oriented hypermedia for Ruby

Topics

Resources

Code of conduct

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages