diff --git a/bridgetown-core/lib/bridgetown-core.rb b/bridgetown-core/lib/bridgetown-core.rb index 3a9dba1de..4a0237fed 100644 --- a/bridgetown-core/lib/bridgetown-core.rb +++ b/bridgetown-core/lib/bridgetown-core.rb @@ -99,6 +99,7 @@ module Bridgetown autoload :Slot, "bridgetown-core/slot" autoload :StaticFile, "bridgetown-core/static_file" autoload :Transformable, "bridgetown-core/concerns/transformable" + autoload :Viewable, "bridgetown-core/concerns/viewable" autoload :Utils, "bridgetown-core/utils" autoload :VERSION, "bridgetown-core/version" autoload :Watcher, "bridgetown-core/watcher" diff --git a/bridgetown-core/lib/bridgetown-core/concerns/viewable.rb b/bridgetown-core/lib/bridgetown-core/concerns/viewable.rb new file mode 100644 index 000000000..f1bca8d71 --- /dev/null +++ b/bridgetown-core/lib/bridgetown-core/concerns/viewable.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Bridgetown + # This mixin for Bridgetown components allows you to provide front matter and render + # the component template via the layouts transformation pipeline, which can be called + # from any Roda route + module Viewable + include Bridgetown::RodaCallable + include Bridgetown::Transformable + + def site + @site ||= Bridgetown::Current.site + end + + def data + @data ||= HashWithDotAccess::Hash.new + end + + def front_matter(&block) + Bridgetown::FrontMatter::RubyFrontMatter.new(data:).tap { _1.instance_exec(&block) } + end + + def relative_path = self.class.source_location.delete_prefix("#{site.root_dir}/") + + # Render the component template in the layout specified in your front matter + # + # @param app [Roda] + def render_in_layout(app) + render_in(app) => rendered_output + + site.validated_layouts_for(self, data.layout).each do |layout| + transform_with_layout(layout, rendered_output, self) => rendered_output + end + + rendered_output + end + + # Pass a block of front matter and render the component template in layouts + # + # @param app [Roda] + def render_with(app, &) + front_matter(&) + render_in_layout(app) + end + end +end diff --git a/bridgetown-core/lib/bridgetown-core/helpers.rb b/bridgetown-core/lib/bridgetown-core/helpers.rb index 1a484ca90..ef30d62dc 100644 --- a/bridgetown-core/lib/bridgetown-core/helpers.rb +++ b/bridgetown-core/lib/bridgetown-core/helpers.rb @@ -11,7 +11,7 @@ class Helpers include ::Streamlined::Helpers include Inclusive - # @return [Bridgetown::RubyTemplateView] + # @return [Bridgetown::RubyTemplateView, Bridgetown::Component] attr_reader :view # @return [Bridgetown::Site] @@ -22,7 +22,7 @@ class Helpers # @return [Bridgetown::Foundation::SafeTranslations] packages def translate_package = [Bridgetown::Foundation::Packages::SafeTranslations] - # @param view [Bridgetown::RubyTemplateView] + # @param view [Bridgetown::RubyTemplateView, Bridgetown::Component] # @param site [Bridgetown::Site] def initialize(view = nil, site = nil) @view = view diff --git a/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb b/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb index 97141c48a..3614b3f9c 100644 --- a/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb +++ b/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb @@ -18,7 +18,7 @@ def self.load_dependencies(app) # This lets us return callable objects directly in Roda response blocks app.handle_block_result(Bridgetown::RodaCallable) do |callable| - callable.(self) + request.send :block_result_body, callable.(self) end end diff --git a/bridgetown-core/test/ssr/config.ru b/bridgetown-core/test/ssr/config.ru index aaee05741..5d65b8886 100644 --- a/bridgetown-core/test/ssr/config.ru +++ b/bridgetown-core/test/ssr/config.ru @@ -10,6 +10,7 @@ require "bridgetown-core/rack/boot" Bridgetown::Rack.boot -require_relative "src/_components/UseRoda" # normally Zeitwerk would take care of this for us +require_relative "src/_components/page_me" # normally Zeitwerk would take care of this for us +require_relative "src/_components/use_roda" # normally Zeitwerk would take care of this for us run RodaApp.freeze.app # see server/roda_app.rb diff --git a/bridgetown-core/test/ssr/server/routes/render_resource.rb b/bridgetown-core/test/ssr/server/routes/render_resource.rb index 4a49e27a2..0cfbe7633 100644 --- a/bridgetown-core/test/ssr/server/routes/render_resource.rb +++ b/bridgetown-core/test/ssr/server/routes/render_resource.rb @@ -16,5 +16,9 @@ class Routes::RenderResource < Bridgetown::Rack::Routes r.get "render_component", String do |title| UseRoda.new(title:) end + + r.get "render_view", String do |title| + PageMe.new(title:) + end end end diff --git a/bridgetown-core/test/ssr/src/_components/page_me.erb b/bridgetown-core/test/ssr/src/_components/page_me.erb new file mode 100644 index 000000000..bd0b98654 --- /dev/null +++ b/bridgetown-core/test/ssr/src/_components/page_me.erb @@ -0,0 +1,9 @@ +
Well that was 246!\n ya think?
" + end end end diff --git a/bridgetown-website/src/_docs/routes.md b/bridgetown-website/src/_docs/routes.md index 78c2e765a..e16d47588 100644 --- a/bridgetown-website/src/_docs/routes.md +++ b/bridgetown-website/src/_docs/routes.md @@ -124,6 +124,50 @@ You can return a resource at the end of any Roda block to have it render out aut Most of the time though, on modestly-sized sites, this shouldn't prove to be a major issue. {% end %} +## Rendering Viewable Components + +For a traditional "VC" part of the MVC (Model-View-Controller) programming paradigm, Bridgetown provides a `Viewable` mixin for [components](/_docs/components). This lets you offload the rendering of a view to a component, keeping your Roda route very clean. + +```ruby +# ./server/routes/products.rb + +class Routes::Products < Bridgetown::Rack::Routes + route do |r| + r.on "products" do + # route: /products/:sku + r.get String do |sku| + # Tip: check out bridgetown_sequel plugin for database connectivity! + Views::Product.new product: Product.find(sku:) + end + end + end +end + +# ./src/_components/views/product.rb + +class Views::Product < Bridgetown::Component + include Bridgetown::Viewable + + def initialize(product:) # rubocop:disable Lint/MissingSuper + @product = product + + data.title = @product.title + end + + # @param app [Roda] this is the instance of the Roda application + def call(app) + render_with(app) do + layout :page + page_class "product" + end + end +end + +# ./src/_components/views/product.erb is an exercise left to the reader +``` + +[Read more about the callable objects pattern below.](#callable-objects-for-rendering-within-blocks) + ## File-based Dynamic Routes **But wait, there’s more!** We also provide a plugin called `bridgetown-routes` which gives you the ability to write file-based dynamic routes with integrated view templates right inside your source folder. @@ -293,6 +337,51 @@ end For the Roda-curious, we've enabled this behavior via our own custom handler for the `custom_block_results` Roda plugin. +And [as mentioned previously](#rendering-viewable-components), the `Viewable` component mixin is a wrapper around `RodaCallable` to add some extra smarts to [Ruby components](/docs/components): + +* You can access the `data` hash from within your component to add and retrieve front matter for the view. +* You can call `front_matter` with a block to define [Ruby Front Matter](/docs/front-matter#the-power-of-ruby-in-front-matter) for the view. +* From your `call(app)` method, you can call `render_in_layout(app)` to render the component template within the layout defined via your front matter. +* Or for a shorthand, call `render_with(app) do ... end` to specify Ruby Front Matter and render the template in one pass. + +You can even cascade multiple callable objects, if you really want a full object-oriented MVC experience: + +```ruby +# ./server/routes/products.rb + +class Routes::Reports < Bridgetown::Rack::Routes + route do |r| + r.on "reports" do + # route: /reports/:id + r.get Integer do |id| + Controllers::Reports::Show.new(id:) + end + end + end +end + +# ./server/controllers/reports/show.rb + +class Controllers::Reports::Show + include Bridgetown::RodaCallable + + def initialize(id:) + @id = id + end + + def call(app) + app => { request:, response: } + + report = Report[@id] + + # do other bits of controller-y logic here + + # render a Viewable component + Views::Reports::Show.new(report:) + end +end +``` +