diff --git a/bridgetown-core/lib/bridgetown-core.rb b/bridgetown-core/lib/bridgetown-core.rb index c5af5ac0a..9e5f5dbbe 100644 --- a/bridgetown-core/lib/bridgetown-core.rb +++ b/bridgetown-core/lib/bridgetown-core.rb @@ -100,6 +100,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/collection.rb b/bridgetown-core/lib/bridgetown-core/collection.rb index 306ed6d62..1d172a62a 100644 --- a/bridgetown-core/lib/bridgetown-core/collection.rb +++ b/bridgetown-core/lib/bridgetown-core/collection.rb @@ -184,7 +184,8 @@ def entry_filter # # @return [String] def inspect - "#<#{self.class} @label=#{label} resources=#{resources}>" + "#<#{self.class} #{label}: #{resources.count} metadata=#{metadata.inspect} " \ + "static_files: #{static_files.count}>" end # Produce a sanitized label name diff --git a/bridgetown-core/lib/bridgetown-core/commands/doctor.rb b/bridgetown-core/lib/bridgetown-core/commands/doctor.rb deleted file mode 100644 index f3c032fa4..000000000 --- a/bridgetown-core/lib/bridgetown-core/commands/doctor.rb +++ /dev/null @@ -1,147 +0,0 @@ -# frozen_string_literal: true - -module Bridgetown - module Commands - class Doctor < Thor::Group - extend BuildOptions - extend Summarizable - include ConfigurationOverridable - - Registrations.register do - register(Doctor, "doctor", "doctor", Doctor.summary) - end - - def self.banner - "bridgetown doctor [options]" - end - summary "Search site and print specific deprecation warnings" - - def doctor - site = Bridgetown::Site.new(configuration_with_overrides(options)) - site.reset - site.read - site.generate - - if healthy?(site) - Bridgetown.logger.info "Your test results", "are in. Everything looks fine." - else - abort - end - end - - protected - - def healthy?(site) - [ - !conflicting_urls(site), - !urls_only_differ_by_case(site), - proper_site_url?(site), - properly_gathered_posts?(site), - ].all? - end - - def properly_gathered_posts?(site) - return true if site.config["collections_dir"].empty? - - posts_at_root = site.in_source_dir("_posts") - return true unless File.directory?(posts_at_root) - - Bridgetown.logger.warn "Warning:", - "Detected '_posts' directory outside custom `collections_dir`!" - Bridgetown.logger.warn "", - "Please move '#{posts_at_root}' into the custom directory at " \ - "'#{site.in_source_dir(site.config["collections_dir"])}'" - false - end - - def conflicting_urls(site) - conflicting_urls = false - urls = {} - urls = collect_urls(urls, site.contents, site.dest) - urls.each do |url, paths| - next unless paths.size > 1 - - conflicting_urls = true - Bridgetown.logger.warn "Conflict:", "The URL '#{url}' is the destination " \ - "for the following pages: #{paths.join(", ")}" - end - conflicting_urls - end - - def urls_only_differ_by_case(site) - urls_only_differ_by_case = false - urls = case_insensitive_urls(site.resources, site.dest) - urls.each_value do |real_urls| - next unless real_urls.uniq.size > 1 - - urls_only_differ_by_case = true - Bridgetown.logger.warn( - "Warning:", - "The following URLs only differ by case. On a case-insensitive file system one of " \ - "the URLs will be overwritten by the other: #{real_urls.join(", ")}" - ) - end - urls_only_differ_by_case - end - - def proper_site_url?(site) - url = site.config["url"] - [ - url_exists?(url), - url_valid?(url), - url_absolute(url), - ].all? - end - - private - - def collect_urls(urls, things, destination) - things.each do |thing| - dest = thing.method(:destination).arity == 1 ? - thing.destination(destination) : - thing.destination - if urls[dest] - urls[dest] << thing.path - else - urls[dest] = [thing.path] - end - end - urls - end - - def case_insensitive_urls(things, _destination) - things.each_with_object({}) do |thing, memo| - dest = thing.destination&.output_path - (memo[dest.downcase] ||= []) << dest if dest - end - end - - def url_exists?(url) - return true unless url.nil? || url.empty? - - Bridgetown.logger.warn "Warning:", "You didn't set an URL in the config file, " \ - "you may encounter problems with some plugins." - false - end - - def url_valid?(url) - Addressable::URI.parse(url) - true - # Addressable::URI#parse only raises a TypeError - # https://git.io/vFfbx - rescue TypeError - Bridgetown.logger.warn "Warning:", "The site URL does not seem to be valid, " \ - "check the value of `url` in your config file." - false - end - - def url_absolute(url) - return true if url.is_a?(String) && Addressable::URI.parse(url).absolute? - - Bridgetown.logger.warn "Warning:", "Your site URL does not seem to be absolute, " \ - "check the value of `url` in your config file." - false - end - end - end -end 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 8041023f2..e642647d9 100644 --- a/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb +++ b/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb @@ -23,7 +23,7 @@ def self.load_dependencies(app, opts = { sessions: false }) # This lets us return callable objects directly in Roda response blocks app.plugin :custom_block_results app.handle_block_result(Bridgetown::RodaCallable) do |callable| - callable.(self) + request.send :block_result_body, callable.(self) end return unless opts[:sessions] 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 @@ +

<%= @title %>

+ +<%= markdownify do %> + * Port <%= @port_number %> + + <%= render Shared::Stuff.new(wild: 123) do %> + _ya think?_ + <% end %> +<% end %> diff --git a/bridgetown-core/test/ssr/src/_components/page_me.rb b/bridgetown-core/test/ssr/src/_components/page_me.rb new file mode 100644 index 000000000..354f3f779 --- /dev/null +++ b/bridgetown-core/test/ssr/src/_components/page_me.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PageMe < Bridgetown::Component + include Bridgetown::Viewable + + def initialize(title:) # rubocop:disable Lint/MissingSuper + @title = title.upcase + + data.title = @title + end + + def call(app) + @port_number = app.request.port + + render_with(app) do + layout :page + page_class "some-extras" + end + end +end diff --git a/bridgetown-core/test/ssr/src/_components/shared/stuff.rb b/bridgetown-core/test/ssr/src/_components/shared/stuff.rb new file mode 100644 index 000000000..4aed5a198 --- /dev/null +++ b/bridgetown-core/test/ssr/src/_components/shared/stuff.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class Shared::Stuff < Bridgetown::Component + def initialize(wild:) # rubocop:disable Lint/MissingSuper + @wild = wild * 2 + end + + def template + "Well that was #{@wild}!#{content}" + end +end diff --git a/bridgetown-core/test/ssr/src/_components/UseRoda.rb b/bridgetown-core/test/ssr/src/_components/use_roda.rb similarity index 100% rename from bridgetown-core/test/ssr/src/_components/UseRoda.rb rename to bridgetown-core/test/ssr/src/_components/use_roda.rb diff --git a/bridgetown-core/test/ssr/src/_data/site_metadata.yml b/bridgetown-core/test/ssr/src/_data/site_metadata.yml new file mode 100644 index 000000000..7708803e2 --- /dev/null +++ b/bridgetown-core/test/ssr/src/_data/site_metadata.yml @@ -0,0 +1 @@ +title: So Awesome \ No newline at end of file diff --git a/bridgetown-core/test/ssr/src/_layouts/default.erb b/bridgetown-core/test/ssr/src/_layouts/default.erb new file mode 100644 index 000000000..316d57cb3 --- /dev/null +++ b/bridgetown-core/test/ssr/src/_layouts/default.erb @@ -0,0 +1,5 @@ +<%= data.title %> | <%= site.metadata.title %> + + +<%= yield %> + \ No newline at end of file diff --git a/bridgetown-core/test/ssr/src/_layouts/page.erb b/bridgetown-core/test/ssr/src/_layouts/page.erb new file mode 100644 index 000000000..ceaf56486 --- /dev/null +++ b/bridgetown-core/test/ssr/src/_layouts/page.erb @@ -0,0 +1,7 @@ +--- +layout: default +--- + +

<%= data.title %>

+ +<%= yield %> \ No newline at end of file diff --git a/bridgetown-core/test/test_doctor_command.rb b/bridgetown-core/test/test_doctor_command.rb deleted file mode 100644 index db735e6c3..000000000 --- a/bridgetown-core/test/test_doctor_command.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -require "helper" -require_all "bridgetown-core/commands/concerns" -require "bridgetown-core/commands/doctor" - -class TestDoctorCommand < BridgetownUnitTest - context "URLs only differ by case" do - setup do - clear_dest - end - - should "return success on a valid site/page" do - @site = Site.new(Bridgetown.configuration( - "source" => File.join(source_dir, "/_urls_differ_by_case_valid"), - "destination" => dest_dir - )) - @site.process - output = capture_stderr do - ret = Bridgetown::Commands::Doctor.new.send(:urls_only_differ_by_case, @site) - assert_equal false, ret - end - assert_equal "", output - end - - should "return warning for pages only differing by case" do - @site = Site.new(Bridgetown.configuration( - "source" => File.join(source_dir, "/_urls_differ_by_case_invalid"), - "destination" => dest_dir - )) - @site.process - output = capture_stderr do - ret = Bridgetown::Commands::Doctor.new.send(:urls_only_differ_by_case, @site) - assert_equal true, ret - end - assert_includes output, "Warning: The following URLs only differ by case. " \ - "On a case-insensitive file system one of the URLs will be overwritten by the " \ - "other: #{dest_dir}/about/index.html, #{dest_dir}/About/index.html" - end - end -end diff --git a/bridgetown-core/test/test_ssr.rb b/bridgetown-core/test/test_ssr.rb index dfb574f80..9b941b486 100644 --- a/bridgetown-core/test/test_ssr.rb +++ b/bridgetown-core/test/test_ssr.rb @@ -104,5 +104,16 @@ def site assert_equal({ "saved" => "Save this value: abc12356" }, JSON.parse(last_response.body)) end + + should "return rendered view" do + get "/render_view/Page_Me" + + assert last_response.ok? + assert_includes last_response.body, "PAGE_ME | So Awesome" + assert_includes last_response.body, "" + assert_includes last_response.body, "

PAGE_ME

" + assert_includes last_response.body, "" + assert_includes last_response.body, "

Well that was 246!\n ya think?

" + end end end diff --git a/bridgetown-website/src/_docs/command-line-usage.md b/bridgetown-website/src/_docs/command-line-usage.md index fecf392f8..335e8d140 100644 --- a/bridgetown-website/src/_docs/command-line-usage.md +++ b/bridgetown-website/src/_docs/command-line-usage.md @@ -17,7 +17,7 @@ Available commands are: * `bridgetown new PATH` - Creates a new Bridgetown site at the specified path with a default configuration and typical site folder structure. * Use the `--apply=` or `-a` option to [apply an automation](/docs/automations) to the new site. * Use the `--configure=` or `-c` option to [apply one or more bundled configurations](/docs/bundled-configurations) to the new site. - * Use the `-t` option to choose ERB or Serbea templates instead of Liquid (aka `-t erb`). + * Use the `-t` option to choose Serbea or Liquid templates instead of ERB (aka `-t serbea`). * Use the `--use-sass` option to configure your project to support Sass. * `bin/bridgetown start` or `s` - Boots the Rack-based server (using Puma) at `localhost:4000`. In development, you'll get live reload functionality as long as `{% live_reload_dev_js %}` or `<%= live_reload_dev_js %>` is in your HTML head. * `bin/bridgetown deploy` - Ensures that all frontend assets get built alongside the published Bridgetown output. This is the command you'll want to use for [deployment](/docs/deployment). @@ -29,7 +29,6 @@ Available commands are: * `bin/bridgetown configure CONFIGURATION` - Run a [bundled configuration](/docs/bundled-configurations) for your existing site. Invoke without arguments to see all available configurations. * `bin/bridgetown date` - Displays the current date and time so you can copy'n'paste it into your front matter. * `bin/bridgetown help` - Shows help, optionally for a given subcommand, e.g. `bridgetown help build`. -* `bin/bridgetown doctor` - Outputs any deprecation or configuration issues. * `bin/bridgetown clean` - Removes all generated files: destination folder, metadata file, and Bridgetown caches. * `bin/bridgetown esbuild ACTION` - Allows you to perform actions such as `update` on your project's esbuild configuration. Invoke without arguments to see all available actions. {% endraw %} diff --git a/bridgetown-website/src/_docs/routes.md b/bridgetown-website/src/_docs/routes.md index a71859a88..891e81ef6 100644 --- a/bridgetown-website/src/_docs/routes.md +++ b/bridgetown-website/src/_docs/routes.md @@ -120,6 +120,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. @@ -289,6 +333,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 +``` +