diff --git a/bridgetown-core/lib/bridgetown-core/concerns/site/ssr.rb b/bridgetown-core/lib/bridgetown-core/concerns/site/ssr.rb
index 1eb39367c..d853b4d97 100644
--- a/bridgetown-core/lib/bridgetown-core/concerns/site/ssr.rb
+++ b/bridgetown-core/lib/bridgetown-core/concerns/site/ssr.rb
@@ -28,6 +28,7 @@ def ssr?
def enable_ssr
Bridgetown.logger.info "SSR:", "enabled."
+ config.fast_refresh = false # SSR mode and Fast Refresh mode are mututally exclusive
@ssr_enabled = true
end
@@ -44,6 +45,7 @@ def ssr_setup(&block)
end
def ssr_first_read
+ # TODO: this shouldn't be running twice, right?!
Bridgetown::Hooks.trigger :site, :pre_read, self
defaults_reader.tap do |d|
d.path_defaults.clear
diff --git a/bridgetown-core/lib/bridgetown-core/rack/boot.rb b/bridgetown-core/lib/bridgetown-core/rack/boot.rb
index f99ad186b..9e934c5a8 100644
--- a/bridgetown-core/lib/bridgetown-core/rack/boot.rb
+++ b/bridgetown-core/lib/bridgetown-core/rack/boot.rb
@@ -3,13 +3,11 @@
require "zeitwerk"
require "roda"
require "json"
-require "roda/plugins/public"
Bridgetown::Current.preloaded_configuration ||= Bridgetown.configuration
require_relative "logger"
require_relative "routes"
-require_relative "static_indexes"
module Bridgetown
module Rack
diff --git a/bridgetown-core/lib/bridgetown-core/rack/routes.rb b/bridgetown-core/lib/bridgetown-core/rack/routes.rb
index 8002248ef..4f53346c5 100644
--- a/bridgetown-core/lib/bridgetown-core/rack/routes.rb
+++ b/bridgetown-core/lib/bridgetown-core/rack/routes.rb
@@ -112,36 +112,11 @@ def merge(roda_app)
new(roda_app).handle_routes
end
- # Start the Roda app request cycle. There are two different code paths
- # depending on if there's a site `base_path` configured
+ # Set up live reload if allowed, then run through all the Routes blocks.
#
# @param roda_app [Roda]
# @return [void]
- def start!(roda_app)
- if Bridgetown::Current.preloaded_configuration.base_path == "/"
- load_all_routes roda_app
- return
- end
-
- # Support custom base_path configurations
- roda_app.request.on(
- Bridgetown::Current.preloaded_configuration.base_path.delete_prefix("/")
- ) do
- load_all_routes roda_app
- end
-
- nil
- end
-
- # Run the Roda public plugin first, set up live reload if allowed, then
- # run through all the Routes blocks. If the file-based router plugin
- # is available, kick off that request process next.
- #
- # @param roda_app [Roda]
- # @return [void]
- def load_all_routes(roda_app)
- roda_app.request.public
-
+ def load_all(roda_app)
if Bridgetown.env.development? &&
!Bridgetown::Current.preloaded_configuration.skip_live_reload
setup_live_reload roda_app
diff --git a/bridgetown-core/lib/bridgetown-core/rack/static_indexes.rb b/bridgetown-core/lib/bridgetown-core/rack/static_indexes.rb
deleted file mode 100644
index 276e134f1..000000000
--- a/bridgetown-core/lib/bridgetown-core/rack/static_indexes.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# frozen_string_literal: true
-
-require "roda/plugins/public"
-
-# TODO: extract out to a standalone Roda plugin
-Roda::RodaPlugins::Public::RequestMethods.module_eval do
- SPLIT = Regexp.union(*[File::SEPARATOR, File::ALT_SEPARATOR].compact) # rubocop:disable Lint/ConstantDefinitionInBlock
- def public_path_segments(path) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
- segments = []
-
- path.split(SPLIT).each do |seg|
- next if seg.empty? || seg == "."
-
- seg == ".." ? segments.pop : segments << seg
- end
-
- path = File.join(roda_class.opts[:public_root], *segments)
- unless File.file?(path)
- path = File.join(path, "index.html")
- if File.file?(path)
- segments << "index.html"
- else
- segments[segments.size - 1] = "#{segments.last}.html"
- end
- end
-
- segments
- rescue IndexError
- nil
- end
-end
diff --git a/bridgetown-core/lib/roda/plugins/bridgetown_server.rb b/bridgetown-core/lib/roda/plugins/bridgetown_server.rb
index 8abc2e7df..900f89a60 100644
--- a/bridgetown-core/lib/roda/plugins/bridgetown_server.rb
+++ b/bridgetown-core/lib/roda/plugins/bridgetown_server.rb
@@ -11,6 +11,7 @@ def self.load_dependencies(app) # rubocop:disable Metrics
"plugin"
end
+ 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
@@ -20,7 +21,7 @@ def self.load_dependencies(app) # rubocop:disable Metrics
app.plugin :json_parser
app.plugin :indifferent_params
app.plugin :cookies, path: "/"
- app.plugin :public, root: Bridgetown::Current.preloaded_configuration.destination
+ app.plugin :ssg, root: Bridgetown::Current.preloaded_configuration.destination
app.plugin :not_found do
output_folder = Bridgetown::Current.preloaded_configuration.destination
File.read(File.join(output_folder, "404.html"))
@@ -51,6 +52,7 @@ def self.load_dependencies(app) # rubocop:disable Metrics
result.transform!.output
end
+ # TODO: there may be a better way to do this, see `exception_page_css` instance method
ExceptionPage.class_eval do # rubocop:disable Metrics/BlockLength
def self.css
<<~CSS
@@ -107,8 +109,16 @@ def self.css
CSS
end
end
+ end
+
+ module ClassMethods
+ def root_hook(&block)
+ opts[:root_hook] = block
+ end
+ end
- app.before do
+ module InstanceMethods
+ def initialize_bridgetown_context
if self.class.opts[:bridgetown_site]
# The site had previously been initialized via the bridgetown_ssr plugin
Bridgetown::Current.sites[self.class.opts[:bridgetown_site].label] =
@@ -117,11 +127,22 @@ def self.css
end
Bridgetown::Current.preloaded_configuration ||=
self.class.opts[:bridgetown_preloaded_config]
+ end
+ def initialize_bridgetown_root # rubocop:todo Metrics/AbcSize
request.root do
- output_folder = Bridgetown::Current.preloaded_configuration.destination
- File.read(File.join(output_folder, "index.html"))
- rescue StandardError
+ hook_result = instance_exec(&self.class.opts[:root_hook]) if self.class.opts[:root_hook]
+ next hook_result if hook_result
+
+ status, headers, body = self.class.opts[:ssg_server].serving(
+ request, File.join(self.class.opts[:ssg_root], "index.html")
+ )
+ response_headers = response.headers
+ response_headers.replace(headers)
+
+ request.halt [status, response_headers, body]
+ rescue StandardError => e
+ Bridgetown.logger.debug("Root handler error: #{e.message}")
response.status = 500
"
ERROR: cannot find index.html
in the output folder.
"
end
@@ -138,9 +159,26 @@ def cookies
_previous_roda_cookies.with_indifferent_access
end
- # Starts up the Bridgetown routing system
+ # Start up the Bridgetown routing system
def bridgetown
- Bridgetown::Rack::Routes.start!(scope)
+ scope.initialize_bridgetown_context
+ scope.initialize_bridgetown_root
+
+ # Run the static file server
+ ssg
+
+ # There are two different code paths depending on if there's a site `base_path` configured
+ if Bridgetown::Current.preloaded_configuration.base_path == "/"
+ Bridgetown::Rack::Routes.load_all scope
+ return
+ end
+
+ # Support custom base_path configurations
+ on(Bridgetown::Current.preloaded_configuration.base_path.delete_prefix("/")) do
+ Bridgetown::Rack::Routes.load_all scope
+ end
+
+ nil
end
end
end
diff --git a/bridgetown-core/lib/roda/plugins/ssg.rb b/bridgetown-core/lib/roda/plugins/ssg.rb
new file mode 100644
index 000000000..51aead908
--- /dev/null
+++ b/bridgetown-core/lib/roda/plugins/ssg.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require "uri"
+require "rack/files"
+
+class Roda
+ module RodaPlugins
+ # This is a simplifed and modified variant of Roda's Public core plugin. It adds additional
+ # functionality so that you can host an entire static site through Roda. What's necessary for
+ # this to work is handling "pretty" URLs, aka:
+ #
+ # /path/to/page -> /path/to/page.html or /path/to/page/index.html
+ # /path/to/page/ -> /path/to/page/index.html
+ #
+ # It does not support serving compressed files, as that should ideally be handled through a
+ # proxy or CDN layer in your architecture.
+ module SSG
+ PARSER = URI::DEFAULT_PARSER
+
+ def self.configure(app, opts = {})
+ app.opts[:ssg_root] = app.expand_path(opts.fetch(:root, "public"))
+ app.opts[:ssg_server] = Rack::Files.new(app.opts[:ssg_root])
+ end
+
+ module RequestMethods
+ def ssg
+ return unless is_get?
+
+ path = PARSER.unescape(real_remaining_path)
+ return if path.include?("\0")
+
+ server = roda_class.opts[:ssg_server]
+ path = File.join(server.root, *segments_for_path(path))
+
+ return unless File.file?(path)
+
+ status, headers, body = server.serving(self, path)
+ response_headers = response.headers
+ response_headers.replace(headers)
+ halt [status, response_headers, body]
+ end
+
+ # TODO: this could be refactored a bit
+ def segments_for_path(path) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
+ segments = []
+
+ path.split("/").each do |seg|
+ next if seg.empty? || seg == "."
+
+ seg == ".." ? segments.pop : segments << seg
+ end
+
+ path = File.join(roda_class.opts[:ssg_root], *segments)
+ unless File.file?(path)
+ path = File.join(path, "index.html")
+ if File.file?(path)
+ segments << "index.html"
+ else
+ segments[segments.size - 1] = "#{segments.last}.html"
+ end
+ end
+
+ segments
+ rescue IndexError
+ nil
+ end
+ end
+ end
+
+ register_plugin :ssg, SSG
+ end
+end
diff --git a/bridgetown-core/lib/site_template/.gitignore b/bridgetown-core/lib/site_template/.gitignore
index 77b98054f..c6a1c8704 100644
--- a/bridgetown-core/lib/site_template/.gitignore
+++ b/bridgetown-core/lib/site_template/.gitignore
@@ -6,7 +6,6 @@ output
# Dependency folders
node_modules
-vendor
# Caches
.sass-cache
diff --git a/bridgetown-routes/lib/bridgetown-routes/manifest.rb b/bridgetown-routes/lib/bridgetown-routes/manifest.rb
index c0ffc3337..46f4db64e 100644
--- a/bridgetown-routes/lib/bridgetown-routes/manifest.rb
+++ b/bridgetown-routes/lib/bridgetown-routes/manifest.rb
@@ -2,110 +2,111 @@
module Bridgetown
module Routes
- module Manifest
- class << self
- def generate_manifest(site) # rubocop:todo Metrics/AbcSize, Metrics/CyclomaticComplexity
- return @route_manifest[site.label] if @route_manifest && Bridgetown.env.production?
+ class Manifest
+ attr_reader :site, :config, :manifest
+
+ def initialize(site, cache_routes: Bridgetown.env.production?)
+ @site = site
+ @manifest = []
+ @config = site.config.routes
+ @cache_routes = cache_routes
+ @islands_dir = File.expand_path(site.config.islands_dir, site.config.source)
+ end
- new_manifest = []
- routable_extensions = site.config.routes.extensions.join(",")
+ def routable_extensions = config.extensions.join(",")
- expand_source_paths_with_islands(site.config).each do |routes_dir|
- routes_dir = File.expand_path(routes_dir, site.config.source)
+ def routes
+ return @manifest if !@manifest.empty? && @cache_routes
- # @type [Array]
- routes = Dir.glob(
- routes_dir + "/**/*.{#{routable_extensions}}"
- ).filter_map do |file|
- if File.basename(file).start_with?("_", ".") ||
- File.basename(file, ".*").end_with?(".test")
- next
- end
+ @manifest = []
- file_slug, segment_keys = file_slug_and_segments(site, routes_dir, file)
+ # Loop through all the directories (`src/_routes`, etc) looking for route files, then
+ # sort them and add them to the manifest:
+ expand_source_paths_with_islands.each do |routes_dir|
+ @manifest += glob_routes(routes_dir).map do |file|
+ file_slug, segment_keys = file_slug_and_segments(routes_dir, file)
- # generate localized file slugs
- localized_file_slugs = generate_localized_file_slugs(site, file_slug)
+ # generate localized file slugs
+ localized_file_slugs = generate_localized_file_slugs(file_slug)
- [file, localized_file_slugs, segment_keys]
- end
+ [file, localized_file_slugs, segment_keys]
+ end.then { sort_routes! _1 }
+ end
- new_manifest += sort_routes!(routes)
- end
+ @manifest
+ end
- @route_manifest ||= {}
- @route_manifest[site.label] = new_manifest
- end
+ def expand_source_paths_with_islands
+ # clear out any past islands folders
+ config.source_paths.reject! { _1.start_with?(@islands_dir) }
- def sort_routes!(routes)
- routes.sort! do |route_a, route_b|
- # @type [String]
- slug_a = route_a[1][0]
- # @type [String]
- slug_b = route_b[1][0]
-
- # @type [Integer]
- weight1 = slug_a.count("/") <=> slug_b.count("/")
- if weight1.zero?
- slug_b.count("/:") <=> slug_a.count("/:")
- else
- weight1
- end
- end.reverse!
+ Dir.glob("#{@islands_dir}/**/routes").each do |route_folder|
+ config.source_paths << route_folder
end
- def locale_for(slug, site)
- possible_locale_segment = slug.split("/").first.to_sym
+ config.source_paths.map { File.expand_path _1, site.config.source }
+ end
- if site.config.available_locales.include? possible_locale_segment
- possible_locale_segment
- else
- site.config.default_locale
- end
+ def glob_routes(dir, pattern = "**/*")
+ files = Dir.glob("#{dir}/#{pattern}.{#{routable_extensions}}")
+ files.reject! do |file|
+ File.basename(file, ".*").then { _1.start_with?("_", ".") || _1.end_with?(".test") }
end
+ files
+ end
- private
-
- # Add any `routes` folders that may be living within islands
- def expand_source_paths_with_islands(config)
- @islands_dir ||= File.expand_path(config.islands_dir, config.source)
+ def file_slug_and_segments(routes_dir, file)
+ # @type [String]
+ file_slug = file.delete_prefix("#{routes_dir}/").then do |f|
+ if routes_dir.start_with?(@islands_dir)
+ # convert _islands/foldername/routes/someroute.rb to foldername/someroute.rb
+ f = routes_dir.delete_prefix("#{@islands_dir}/").sub(%r!/routes$!, "/") + f
+ end
+ [File.dirname(f), File.basename(f, ".*")].join("/").delete_prefix("./")
+ end.delete_suffix("/index")
+ segment_keys = []
+ file_slug.gsub!(%r{\[([^/]+)\]}) do |_segment|
+ segment_keys << Regexp.last_match(1)
+ ":#{Regexp.last_match(1)}"
+ end
- # clear out any past islands folders
- config.routes.source_paths.reject! { _1.start_with?(@islands_dir) }
+ [file_slug, segment_keys]
+ end
- Dir.glob("#{@islands_dir}/**/routes").each do |route_folder|
- config.routes.source_paths << route_folder
+ def generate_localized_file_slugs(file_slug)
+ site.config.available_locales.map do |locale|
+ if locale == site.config.default_locale && !site.config.prefix_default_locale
+ file_slug
+ else
+ "#{locale}/#{file_slug}"
end
-
- config.routes.source_paths
end
+ end
- def file_slug_and_segments(_site, routes_dir, file)
+ def sort_routes!(routes)
+ routes.sort! do |route_a, route_b|
+ # @type [String]
+ slug_a = route_a[1][0]
# @type [String]
- file_slug = file.delete_prefix("#{routes_dir}/").then do |f|
- if routes_dir.start_with?(@islands_dir)
- # convert _islands/foldername/routes/someroute.rb to foldername/someroute.rb
- f = routes_dir.delete_prefix("#{@islands_dir}/").sub(%r!/routes$!, "/") + f
- end
- [File.dirname(f), File.basename(f, ".*")].join("/").delete_prefix("./")
- end.delete_suffix("/index")
- segment_keys = []
- file_slug.gsub!(%r{\[([^/]+)\]}) do |_segment|
- segment_keys << Regexp.last_match(1)
- ":#{Regexp.last_match(1)}"
+ slug_b = route_b[1][0]
+
+ # @type [Integer]
+ weight1 = slug_a.count("/") <=> slug_b.count("/")
+ if weight1.zero?
+ slug_b.count("/:") <=> slug_a.count("/:")
+ else
+ weight1
end
+ end.reverse!
+ end
- [file_slug, segment_keys]
- end
+ def locale_for(slug)
+ possible_locale_segment = slug.split("/").first.to_sym
- def generate_localized_file_slugs(site, file_slug)
- site.config.available_locales.map do |locale|
- if locale == site.config.default_locale && !site.config.prefix_default_locale
- file_slug
- else
- "#{locale}/#{file_slug}"
- end
- end
+ if site.config.available_locales.include? possible_locale_segment
+ possible_locale_segment
+ else
+ site.config.default_locale
end
end
end
diff --git a/bridgetown-routes/lib/bridgetown-routes/manifest_router.rb b/bridgetown-routes/lib/bridgetown-routes/manifest_router.rb
index aa823722a..880696216 100644
--- a/bridgetown-routes/lib/bridgetown-routes/manifest_router.rb
+++ b/bridgetown-routes/lib/bridgetown-routes/manifest_router.rb
@@ -1,55 +1,11 @@
# frozen_string_literal: true
-require "bridgetown-core/rack/routes"
-
module Bridgetown
module Routes
class ManifestRouter < Bridgetown::Rack::Routes
priority :lowest
- route do |r|
- unless bridgetown_site
- Bridgetown.logger.warn(
- "The `bridgetown_routes` plugin hasn't been configured in the Roda app."
- )
- return
- end
-
- Bridgetown::Routes::Manifest.generate_manifest(bridgetown_site).each do |route|
- file, localized_file_slugs, segment_keys = route
-
- localized_file_slugs.each do |file_slug|
- add_route(r, file, file_slug, segment_keys)
- end
- end
-
- nil
- end
-
- private
-
- def add_route(route, file, file_slug, segment_keys)
- route.on file_slug do |*segment_values|
- response["X-Bridgetown-SSR"] = "1"
- # eval_route_file caches when Bridgetown.env.production?
- Bridgetown::Routes::CodeBlocks.eval_route_file file, file_slug, @_roda_app
-
- segment_values.each_with_index do |value, index|
- route.params[segment_keys[index]] ||= value
- end
-
- # set route locale
- locale = Bridgetown::Routes::Manifest.locale_for(file_slug, bridgetown_site)
- I18n.locale = locale
- route.params[:locale] = locale
-
- route_block = Bridgetown::Routes::CodeBlocks.route_block(file_slug)
- response.instance_variable_set(
- :@_route_file_code, route_block.instance_variable_get(:@_route_file_code)
- ) # could be nil
- @_roda_app.instance_exec(route, &route_block)
- end
- end
+ route(&:file_routes)
end
end
end
diff --git a/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb b/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb
index 72c13d537..ba771d27e 100644
--- a/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb
+++ b/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb
@@ -25,13 +25,49 @@ def self.load_dependencies(app)
end
def self.configure(app, _opts = {})
- return unless app.opts[:bridgetown_site].nil?
+ app.root_hook do
+ routes_dir = File.expand_path(
+ bridgetown_site.config.routes.source_paths.first,
+ bridgetown_site.config.source
+ )
+ file = routes_manifest.glob_routes(routes_dir, "index").first
+ next unless file
+
+ run_file_route(file, slug: "index")
+ end
+
+ if app.opts[:bridgetown_site]
+ app.opts[:routes_manifest] ||=
+ Bridgetown::Routes::Manifest.new(app.opts[:bridgetown_site])
+ return
+ end
raise "Roda app failure: the bridgetown_ssr plugin must be registered before " \
"bridgetown_routes"
end
module InstanceMethods
+ def routes_manifest
+ self.class.opts[:routes_manifest]
+ end
+
+ def run_file_route(file, slug:)
+ response["X-Bridgetown-Routes"] = "1"
+ # eval_route_file caches when Bridgetown.env.production?
+ Bridgetown::Routes::CodeBlocks.eval_route_file file, slug, self
+
+ # set route locale
+ locale = routes_manifest.locale_for(slug)
+ I18n.locale = request.params[:locale] = locale
+
+ # get the route block extracted from the file at slug
+ route_block = Bridgetown::Routes::CodeBlocks.route_block(slug)
+ response.instance_variable_set(
+ :@_route_file_code, route_block.instance_variable_get(:@_route_file_code)
+ ) # could be nil
+ instance_exec(request, &route_block)
+ end
+
def front_matter(&block)
b = block.binding
denylisted = %i(r app code ruby_content code_postmatch)
@@ -85,6 +121,32 @@ def view(view_class: Bridgetown::ERBView)
end
end
+ module RequestMethods
+ # This runs through all of the routes in the manifest, setting up Roda blocks for execution
+ def file_routes
+ scope.routes_manifest.routes.each do |route|
+ file, localized_file_slugs, segment_keys = route
+
+ localized_file_slugs.each do |slug|
+ # This sets up an initial Roda route block at the slug, and handles segments as params
+ #
+ # _routes/nested/[slug].erb -> "nested/:slug"
+ # "nested/123" -> r.params[:slug] == 123
+ on slug do |*segment_values|
+ segment_values.each_with_index do |value, index|
+ params[segment_keys[index]] ||= value
+ end
+
+ # This is provided as an instance method by our Roda plugin:
+ scope.run_file_route(file, slug:)
+ end
+ end
+ end
+
+ nil # be sure not to return the above array loop
+ end
+ end
+
module ResponseMethods
# template string provided, if available, by the saved code block
def _route_file_code
diff --git a/bridgetown-routes/test/ssr/src/_routes/test_index.erb b/bridgetown-routes/test/ssr/src/_routes/test_index.erb
new file mode 100644
index 000000000..90f05122c
--- /dev/null
+++ b/bridgetown-routes/test/ssr/src/_routes/test_index.erb
@@ -0,0 +1,8 @@
+---<%
+# Note: this file gets renamed to `index.erb` temporarily during the test!
+render_with do
+ title "Dynamic Index"
+end
+%>---
+
+<%= data.title %>
\ No newline at end of file
diff --git a/bridgetown-routes/test/ssr/src/index.md b/bridgetown-routes/test/ssr/src/index.md
deleted file mode 100644
index 0317634c8..000000000
--- a/bridgetown-routes/test/ssr/src/index.md
+++ /dev/null
@@ -1 +0,0 @@
-Hello **world**!
\ No newline at end of file
diff --git a/bridgetown-routes/test/test_routes.rb b/bridgetown-routes/test/test_routes.rb
index b33fa9b6c..7752d1a4a 100644
--- a/bridgetown-routes/test/test_routes.rb
+++ b/bridgetown-routes/test/test_routes.rb
@@ -15,12 +15,21 @@ def site
end
context "Roda-powered Bridgetown server" do # rubocop:todo Metrics/BlockLength
- should "return the index page" do
+ should "return the static index page" do
get "/"
assert last_response.ok?
assert_equal "Index
", last_response.body
end
+ should "return the dynamic index page if present" do
+ index_file = File.expand_path("ssr/src/_routes/test_index.erb", __dir__)
+ FileUtils.cp(index_file, index_file.sub("test_index.erb", "index.erb"))
+ get "/"
+ assert last_response.ok?
+ assert_equal "Dynamic Index
", last_response.body
+ FileUtils.remove_file(index_file.sub("test_index.erb", "index.erb"))
+ end
+
should "return JSON for the hello route" do
get "/hello/world"
assert last_response.ok?