Skip to content

Commit

Permalink
Refactor Roda plugin setup, add Roda "distribution" docs (WIP) (#969)
Browse files Browse the repository at this point in the history
* Move some Roda plugins out of server default and into SSR plugin

- Add new documentation on how Roda is configured
- BREAKING CHANGE: the `flash` plugin is no longer loaded by the file-based routes plugin by default, it's now configured with `sessions: true` in the ssr plugin init. Flash required sessions to be set up anyway, which used to be manual but is now provided by this new config.

* Flesh out the Roda reference guide

* A few additional tweaks to Roda docs

* remove unnecessary Rubocop code in docs example
  • Loading branch information
jaredcwhite authored Jan 12, 2025
1 parent 289192f commit fff4419
Show file tree
Hide file tree
Showing 15 changed files with 208 additions and 88 deletions.
8 changes: 0 additions & 8 deletions bridgetown-core/lib/bridgetown-core/rack/boot.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,6 @@ def self.boot(*)
LoaderHooks.autoload_server_folder(
File.join(Bridgetown::Current.preloaded_configuration.root_dir, "server")
)
rescue Roda::RodaError => e
if e.message.include?("sessions plugin :secret option")
raise Bridgetown::Errors::InvalidConfigurationError,
"The Roda sessions plugin can't find a valid secret. Run `bin/bridgetown secret' " \
"and put the key in a ENV var you can use to configure the session in the Roda app"
end

raise e
end
end
end
4 changes: 2 additions & 2 deletions bridgetown-core/lib/bridgetown-core/utils/initializers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
Bridgetown.load_dotenv root: config.root_dir
end

Bridgetown.initializer :ssr do |config, setup: nil|
Bridgetown.initializer :ssr do |config, setup: nil, **options|
config.roda do |app|
app.plugin(:bridgetown_ssr, &setup)
app.plugin(:bridgetown_ssr, options, &setup)
end
end

Expand Down
5 changes: 0 additions & 5 deletions bridgetown-core/lib/roda/plugins/bridgetown_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,9 @@ def self.load_dependencies(app) # rubocop:disable Metrics

app.extend ClassMethods # we need to do this here because Roda hasn't done it yet
app.plugin :initializers
app.plugin :method_override
app.plugin :all_verbs
app.plugin :hooks
app.plugin :common_logger, Bridgetown::Rack::Logger.new($stdout), method: :info
app.plugin :json
app.plugin :json_parser
app.plugin :indifferent_params
app.plugin :cookies, path: "/"
app.plugin :ssg, root: Bridgetown::Current.preloaded_configuration.destination
app.plugin :not_found do
output_folder = Bridgetown::Current.preloaded_configuration.destination
Expand Down
22 changes: 20 additions & 2 deletions bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,31 @@ def bridgetown_site
alias_method :site, :bridgetown_site
end

def self.load_dependencies(app)
app.plugin :custom_block_results
def self.load_dependencies(app, opts = { sessions: false })
app.plugin :all_verbs
app.plugin :cookies, path: "/"
app.plugin :indifferent_params
app.plugin :method_override
app.plugin :route_csrf

# This lets us return callable objects directly in Roda response blocks
app.plugin :custom_block_results
app.handle_block_result(Bridgetown::RodaCallable) do |callable|
request.send :block_result_body, callable.(self)
end

return unless opts[:sessions]

secret_key = ENV.fetch("RODA_SECRET_KEY", nil)
unless secret_key
raise Bridgetown::Errors::InvalidConfigurationError,
"The Roda sessions plugin can't find a valid secret. Run " \
"`bin/bridgetown secret' and put the key in your ENV as the " \
"RODA_SECRET_KEY variable"
end

app.plugin :sessions, secret: secret_key
app.plugin :flashier
end

def self.configure(app, _opts = {}, &)
Expand Down
57 changes: 57 additions & 0 deletions bridgetown-core/lib/roda/plugins/flashier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

class Roda
module RodaPlugins
module Flashier
module FlashHashAdditions
def info
self["info"]
end

def info=(val)
self["info"] = val
end

def alert
self["alert"]
end

def alert=(val)
self["alert"] = val
end
end

module FlashHashIndifferent
def []=(key, val)
@next[key.to_s] = val
end
end

module FlashNowHashIndifferent
def []=(key, val)
super(key.to_s, val)
end

def [](key)
super(key.to_s)
end
end

def self.load_dependencies(app)
require "roda/plugins/flash"

Roda::RodaPlugins::Flash::FlashHash.include FlashHashAdditions, FlashHashIndifferent
Roda::RodaPlugins::Flash::FlashHash.class_eval do
def initialize(hash = {})
super(hash || {})
now.singleton_class.include FlashHashAdditions, FlashNowHashIndifferent
@next = {}
end
end
app.plugin :flash
end
end

register_plugin :flashier, Flashier
end
end
2 changes: 2 additions & 0 deletions bridgetown-core/lib/site_template/config/initializers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@
#
# init :ssr
#
# Add `sessions: true` if you need to use session data, flash, etc.
#

# Uncomment to use file-based dynamic template routing via Roda (make sure you
# uncomment the gem dependency in your `Gemfile` as well):
Expand Down
1 change: 1 addition & 0 deletions bridgetown-core/test/ssr/config/local_ssr_init.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

Bridgetown.initializer :local_ssr_init do |config|
config.init :ssr do
sessions true
setup -> site do # rubocop:disable Layout/SpaceInLambdaLiteral, Style/StabbyLambdaParentheses
site.data.iterations ||= 0
site.data.iterations += 1
Expand Down
14 changes: 14 additions & 0 deletions bridgetown-core/test/ssr/server/routes/flashy.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

class Routes::Flashy < Bridgetown::Rack::Routes
route do |r|
# route: POST /flashy/:name
r.post "flashy", String do |name|
flash.info = "Save this value: #{name}"
end

r.get "flashy" do
{ saved: flash.info }
end
end
end
13 changes: 12 additions & 1 deletion bridgetown-core/test/test_ssr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ class TestSSR < BridgetownUnitTest
include Rack::Test::Methods

def app
@@ssr_app ||= Rack::Builder.parse_file(File.expand_path("ssr/config.ru", __dir__)) # rubocop:disable Style/ClassVars
@@ssr_app ||= begin # rubocop:disable Style/ClassVars
ENV["RODA_SECRET_KEY"] = SecureRandom.hex(64)
Rack::Builder.parse_file(File.expand_path("ssr/config.ru", __dir__))
end
end

def site
Expand Down Expand Up @@ -94,6 +97,14 @@ def site
assert_equal "<rss>WOW true</rss>", last_response.body
end

should "return flash value" do
post "/flashy/abc12356"

get "/flashy"

assert_equal({ "saved" => "Save this value: abc12356" }, JSON.parse(last_response.body))
end

should "return rendered view" do
get "/render_view/Page_Me"

Expand Down
39 changes: 0 additions & 39 deletions bridgetown-routes/lib/bridgetown-routes/flash_additions.rb

This file was deleted.

18 changes: 15 additions & 3 deletions bridgetown-routes/lib/bridgetown-routes/view_helpers.rb
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
# frozen_string_literal: true

require_relative "flash_additions"

module Bridgetown
module Routes
module ViewHelpers
# This flash is only used as a stub for views in case there's no Roda flash
# available in the rendering context
class Flash < Hash
include Bridgetown::Routes::FlashHashAdditions
def info
self["info"]
end

def info=(val)
self["info"] = val
end

def alert
self["alert"]
end

def alert=(val)
self["alert"] = val
end

def now
self
Expand Down
16 changes: 0 additions & 16 deletions bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb
Original file line number Diff line number Diff line change
@@ -1,27 +1,11 @@
# frozen_string_literal: true

require "roda/plugins/flash"
require_relative "../../bridgetown-routes/flash_additions"

Roda::RodaPlugins::Flash::FlashHash.include Bridgetown::Routes::FlashHashAdditions,
Bridgetown::Routes::FlashHashIndifferent
Roda::RodaPlugins::Flash::FlashHash.class_eval do
def initialize(hash = {})
super(hash || {})
now.singleton_class.include Bridgetown::Routes::FlashHashAdditions,
Bridgetown::Routes::FlashNowHashIndifferent
@next = {}
end
end

class Roda
module RodaPlugins
module BridgetownRoutes
def self.load_dependencies(app)
app.plugin :slash_path_empty # now /hello and /hello/ are both matched
app.plugin :placeholder_string_matchers
app.plugin :flash
app.plugin :route_csrf
end

def self.configure(app, _opts = {})
Expand Down
10 changes: 4 additions & 6 deletions bridgetown-website/src/_docs/configuration/initializers.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Bridgetown.configure do |config|
config.autoload_paths << "jobs"

init :ssr do
sessions true
setup -> site do
# perform site setup tasks only in the server context
end
Expand Down Expand Up @@ -215,23 +216,20 @@ While it's not strictly required that you place a Roda block inside of an `only

### SSR & Dynamic Routes

The SSR features of Bridgetown, along with its companion file-based routing features, are now configurable via initializers.
The SSR features of Bridgetown, along with its companion file-based routing features, are configurable via initializers.

```rb
init :ssr
init :ssr, sessions: true # the dotenv initializer is also recommended, more on that below

# optional:
init :"bridgetown-routes"

# …or you can just init the routes, which will init :ssr automatically:

init :"bridgetown-routes"
```

If you want to run some specific site setup code on first boot, or any time there's a file refresh in development, provide a `setup` block inside of the SSR initializer.

```rb
init :ssr do
sessions true
setup -> site do
# access the site object, add data with `site.data`, whatever
end
Expand Down
71 changes: 71 additions & 0 deletions bridgetown-website/src/_docs/roda.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
title: Roda Reference Guide
order: 255
top_section: Architecture
category: roda
---

Bridgetown comes with what we like to call an "opinionated distribution" of the [Roda web toolkit](https://roda.jeremyevans.net), meaning we've configured a number of plugins right out of the box for enhanced developer experience.

{%@ Note do %}
For a general overview of how to author server-side code, see [Server-Rendered Routes](/docs/routes).
{% end %}

This base configuration is itself provided by the `bridgetown_server` plugin. On a fresh install of a Bridgetown site, you'll get a `server/roda_app.rb` file with the following:

```ruby
class RodaApp < Roda
plugin :bridgetown_server

route do |r|
r.bridgetown
end
end
```

The `r.bridgetown` method call spins up Bridgetown's own routing system which is comprised of subclasses of `Bridgetown::Rack::Routes`and if the `bridgetown-routes` plugin is active, file-based routing (in `src/_routes`) as well.

The `bridgetown_server` plugin configures the following Roda plugins:

* [common_logger](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/CommonLogger.html) - connects Roda up with Bridgetown's logger
* [json](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Json.html) - allows arrays or hashes returned from a route block to be converted to JSON output automatically, along with setting `application/json` as the content type for the response
* [json_parser](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/JsonParser.html) - parses incoming JSON data and provides it via `r.POST` and also `r.params`

Bridgetown also sets up the [not_found](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/NotFound.html), [exception_page](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/ExceptionPage.html), and [error_handler](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/ErrorHandler.html) plugins to deal with errors that may arise when processing a request.

We also load our custom `ssg` plugin which is loosely based on Roda's `public` plugin and provides serving of static assets using "pretty URLs", aka:

* `/path/to/page` -> `/path/to/page.html` or `/path/to/page/index.html`
* `/path/to/page/` -> `/path/to/page/index.html`

If you add `init :ssr` to your [Initializers](/docs/configuration/initializers) config, the `bridgetown_ssr` plugin is loaded which configures these additional plugins:

* [all_verbs](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/AllVerbs.html) - adds routing methods for additional HTTP verbs like PUT, PATCH, DELETE, etc.
* [cookies](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Cookies.html) - adds response methods for setting or deleting cookies, default path is root (`/`)
* [indifferent_params](http://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/IndifferentParams.html) - lets you access request params using symbols in addition to strings, and also provides a `params` instance method (no need to use `r.`)
* [route_csrf](https://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/RouteCsrf.html) - this helps protect against cross-site request forgery in form submissions
* [custom_block_results](https://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/CustomBlockResults.html) - lets Roda route blocks return arbitrary objects which can be processed with custom handlers. We use this to enable our [RodaCallable](/docs/routes#callable-objects-for-rendering-within-blocks) functionality
* `method_override` - this Bridgetown-supplied plugin looks for the presence of a `_method` form param and will use that to override the incoming HTTP request method. Thus even if a form comes in as POST, if `_method` equals `PUT` the request method will be `PUT`.

If you pass `sessions: true` to the `ssr` initializer in your config, you'll get these plugins added:

* [sessions](https://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Sessions.html) - adds support for cookie-based session storage (aka a small amount of key-val data storage which persists across requests for a single client). You'll need to have `RODA_SECRET_KEY` defined in your environment. To make this easy in local development, you should set up the [Dotenv gem](/docs/configuration/initializers#dotenv). Setting up a secret key is a matter of running `bin/bridgetown secret` and then copying the key to `RODA_SECRET_KEY`.
* [flash](https://roda.jeremyevans.net/rdoc/classes/Roda/RodaPlugins/Flash.html) - provides a `flash` object you can access from both routes and view templates.

The following `flash` methods are available:

* `flash.info = "..."` / `flash.info` - set an informational message in a route which can then be read once after a redirect.
* `flash.alert = "..."` / `flash.alert` - set an alert message in a route which can then be read once after a redirect.
* `flash.now` - lets you set and retrieve flash messages (both `.info` and `.alert`) for the current request/response cycle.

## Bridgetown Route Classes

If you've come to Bridgetown already familiar with Roda, you may be wondering what `Bridgetown::Rack::Routes` is and how it works.

Because a traditional Roda application isn't oriented towards an architecture which plays well with Zeitwerk-based reloading in development, we decided to eschew Roda's recommended solutions for creating multiple route files (such as the `hash_branches` plugin) in favor of a class-based solution.

During the `r.bridgetown` routing process, every loaded `Bridgetown::Rack::Routes` class is evaluated in turn (sorted by [priority](/docs/routes#priority-flag)) until a route handler has been found (or in lieu of that, a generic 404 response is returned). Route handlers are provided via the `route` class method, and the block you provide is evaluated in an instance of that class (not the Roda application itself).

{%@ Note do %}
You can still access methods of the Roda application from within a route block because `Bridgetown::Rack::Routes` defines `method_missing` and delegates calls accordingly. So for example if you were to call `flash` in your route block, that call would be passed along to the Roda application. However, if for some reason you were to write `def flash` to create an instance method, you'd no longer have access to Roda's `flash`. So it's recommended that if you do write custom instance methods, you avoid using names which interfere with typical Roda app methods.
{% end %}
Loading

0 comments on commit fff4419

Please sign in to comment.