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?