diff --git a/CHANGELOG.md b/CHANGELOG.md index f4cd36d11..7c280362f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix: Lock Liquid to version < 5.5 (unresolved data leakage and generation errors otherwise) - Fix: esbuild file endings from previous release +- Use bundler binstubs to create `bin/bt` shortcut instead of by `cp` ## [1.3.3] - 2024-03-16 diff --git a/Gemfile.lock b/Gemfile.lock index 9ced064d4..fd02a5a03 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -23,6 +23,7 @@ PATH liquid (>= 5.0, < 5.5) listen (~> 3.0) rack (>= 3.0) + rackup (~> 2.0) rake (>= 13.0) roda (~> 3.46) rouge (>= 3.0, < 5.0) @@ -129,10 +130,10 @@ GEM racc (~> 1.4) nokogiri (1.16.4-x86_64-linux) racc (~> 1.4) - nokolexbor (0.5.3) - nokolexbor (0.5.3-arm64-darwin) - nokolexbor (0.5.3-x86_64-darwin) - nokolexbor (0.5.3-x86_64-linux) + nokolexbor (0.5.4) + nokolexbor (0.5.4-arm64-darwin) + nokolexbor (0.5.4-x86_64-darwin) + nokolexbor (0.5.4-x86_64-linux) parallel (1.24.0) parser (3.3.0.5) ast (~> 2.4.1) @@ -142,6 +143,9 @@ GEM rack (3.0.10) rack-test (2.1.0) rack (>= 1.3) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) rainbow (3.1.1) rake (13.2.1) rb-fsevent (0.11.2) @@ -223,6 +227,7 @@ GEM concurrent-ruby (~> 1.0) unicode-display_width (2.5.0) uri (0.13.0) + webrick (1.8.1) yard (0.9.36) zeitwerk (2.6.13) diff --git a/bridgetown-core/bin/bt b/bridgetown-core/bin/bt new file mode 100755 index 000000000..507ff72fe --- /dev/null +++ b/bridgetown-core/bin/bt @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# `bt` is a shortcut to `bridgetown` + +load File.join(File.dirname(__FILE__), 'bridgetown') diff --git a/bridgetown-core/bridgetown-core.gemspec b/bridgetown-core/bridgetown-core.gemspec index 538461ad6..e26b04f81 100644 --- a/bridgetown-core/bridgetown-core.gemspec +++ b/bridgetown-core/bridgetown-core.gemspec @@ -15,7 +15,7 @@ Gem::Specification.new do |s| s.files = `git ls-files -z`.split("\x0").reject do |f| f.match(%r!^(benchmark|features|script|test)/!) end - s.executables = ["bridgetown"] + s.executables = ["bridgetown", "bt"] # `bt` is a shortcut to `bridgetown` command s.bindir = "bin" s.require_paths = ["lib"] @@ -46,6 +46,7 @@ Gem::Specification.new do |s| s.add_runtime_dependency("liquid", [">= 5.0", "< 5.5"]) s.add_runtime_dependency("listen", "~> 3.0") s.add_runtime_dependency("rack", ">= 3.0") + s.add_runtime_dependency("rackup", "~> 2.0") s.add_runtime_dependency("rake", ">= 13.0") s.add_runtime_dependency("roda", "~> 3.46") s.add_runtime_dependency("rouge", [">= 3.0", "< 5.0"]) diff --git a/bridgetown-core/lib/bridgetown-core.rb b/bridgetown-core/lib/bridgetown-core.rb index bc362b85e..fd62d5eb7 100644 --- a/bridgetown-core/lib/bridgetown-core.rb +++ b/bridgetown-core/lib/bridgetown-core.rb @@ -101,7 +101,6 @@ module Bridgetown autoload :Slot, "bridgetown-core/slot" autoload :StaticFile, "bridgetown-core/static_file" autoload :Transformable, "bridgetown-core/concerns/transformable" - autoload :URL, "bridgetown-core/url" autoload :Utils, "bridgetown-core/utils" autoload :VERSION, "bridgetown-core/version" autoload :Watcher, "bridgetown-core/watcher" @@ -375,9 +374,7 @@ def build_errors_path ) end end -end -module Bridgetown module Model; end module Resource @@ -390,6 +387,13 @@ def self.register_extension(mod) end end end + + # mixin for identity so Roda knows to call renderable objects + module RodaCallable + def self.===(other) + other.class < self + end + end end Zeitwerk.with_loader do |l| diff --git a/bridgetown-core/lib/bridgetown-core/commands/build.rb b/bridgetown-core/lib/bridgetown-core/commands/build.rb index c09a69648..ae4d2047a 100644 --- a/bridgetown-core/lib/bridgetown-core/commands/build.rb +++ b/bridgetown-core/lib/bridgetown-core/commands/build.rb @@ -31,23 +31,22 @@ def self.print_startup_message def build Bridgetown.logger.adjust_verbosity(options) - unless caller_locations.find do |loc| - loc.to_s.include?("bridgetown-core/commands/start.rb") - end - self.class.print_startup_message - end - # @type [Bridgetown::Configuration] config_options = configuration_with_overrides( options, Bridgetown::Current.preloaded_configuration ) - config_options.run_initializers! context: :static + self.class.print_startup_message unless config_options["start_command"] - config_options["serving"] = false unless config_options["serving"] + Process.setproctitle( + "bridgetown #{Bridgetown::VERSION} " \ + "(#{config_options["start_command"] ? "start" : "build"}) [#{File.basename(Dir.pwd)}]" + ) + + config_options.run_initializers! context: :static if !Bridgetown.env.production? && - !config_options[:skip_frontend] && config_options["using_puma"] + !config_options[:skip_frontend] && config_options["start_command"] if Bridgetown::Utils.frontend_bundler_type(config_options[:root_dir]) == :esbuild Bridgetown::Utils.update_esbuild_autogenerated_config config_options end @@ -96,19 +95,6 @@ def build_site(config_options) @site.process Bridgetown.logger.info "Done! 🎉", "#{"Completed".bold.green} in less than " \ "#{(Time.now - t).ceil(2)} seconds." - - return unless config_options[:using_puma] - - require "socket" - external_ip = Socket.ip_address_list.find do |ai| - ai.ipv4? && !ai.ipv4_loopback? - end&.ip_address - scheme = config_options.bind&.split("://")&.first == "ssl" ? "https" : "http" - port = config_options.bind&.split(":")&.last || ENV["BRIDGETOWN_PORT"] || 4000 - Bridgetown.logger.info "" - Bridgetown.logger.info "Now serving at:", "#{scheme}://localhost:#{port}".magenta - Bridgetown.logger.info "", "#{scheme}://#{external_ip}:#{port}".magenta if external_ip - Bridgetown.logger.info "" end # Watch for file changes and rebuild the site. diff --git a/bridgetown-core/lib/bridgetown-core/commands/new.rb b/bridgetown-core/lib/bridgetown-core/commands/new.rb index 2994f910b..70cbe317e 100644 --- a/bridgetown-core/lib/bridgetown-core/commands/new.rb +++ b/bridgetown-core/lib/bridgetown-core/commands/new.rb @@ -224,8 +224,8 @@ def bundle_install(path) Bridgetown.with_unbundled_env do inside(path) do run "bundle install", abort_on_failure: true + # create binstubs to `bin/bridgetown` and `bin/bt` run "bundle binstubs bridgetown-core" - run "cp bin/bridgetown bin/bt" end end end diff --git a/bridgetown-core/lib/bridgetown-core/commands/start.rb b/bridgetown-core/lib/bridgetown-core/commands/start.rb index 6d0ca3374..dca2a7590 100644 --- a/bridgetown-core/lib/bridgetown-core/commands/start.rb +++ b/bridgetown-core/lib/bridgetown-core/commands/start.rb @@ -1,103 +1,118 @@ # frozen_string_literal: true +require "rackup/server" + module Bridgetown + class Server < Rackup::Server + def start(after_stop_callback = nil) + trap(:INT) { exit } + super() + ensure + after_stop_callback&.call + end + + def name + server.to_s.split("::").last + end + + def using_puma? + name == "Puma" + end + + def serveable? + server + true + rescue LoadError, NameError + false + end + end + module Commands class Start < Thor::Group extend BuildOptions extend Summarizable include ConfigurationOverridable + include Bridgetown::Utils::PidTracker Registrations.register do register(Start, "start", "start", Start.summary) register(Start, "dev", "dev", "Alias of start") end - class_option :bind, aliases: "-B", desc: "URI for Puma to bind to (start with tcp://)" + class_option :port, + aliases: "-P", + type: :numeric, + default: 4000, + desc: "Serve your site on the specified port. Defaults to 4000." + class_option :bind, + aliases: "-B", + type: :string, + default: "0.0.0.0", + desc: "URL for the server to bind to." class_option :skip_frontend, type: :boolean, - desc: "Don't load the frontend bundler (always true for production)" + desc: "Don't load the frontend bundler (always true for production)." class_option :skip_live_reload, type: :boolean, - desc: "Don't use the live reload functionality (always true for production)" + desc: "Don't use the live reload functionality (always true for production)." def self.banner "bridgetown start [options]" end - summary "Start the Puma server, frontend bundler, and Bridgetown watcher" + summary "Start the web server, frontend bundler, and Bridgetown watcher" - def start # rubocop:todo Metrics/PerceivedComplexity + def start Bridgetown.logger.writer.enable_prefix Bridgetown::Commands::Build.print_startup_message sleep 0.25 - begin - require("puma/detect") - rescue LoadError - raise "** Puma server gem not found. Check your Gemfile and Bundler env? **" - end - options = Thor::CoreExt::HashWithIndifferentAccess.new(self.options) - options[:using_puma] = true + options[:start_command] = true # Load Bridgetown configuration into thread memory bt_options = configuration_with_overrides(options) + port = ENV.fetch("BRIDGETOWN_PORT", bt_options.port) + # TODO: support Puma serving HTTPS directly? + bt_bound_url = "http://#{bt_options.bind}:#{port}" # Set a local site URL in the config if one is not available - if Bridgetown.env.development? && !options["url"] - scheme = bt_options.bind&.split("://")&.first == "ssl" ? "https" : "http" - port = bt_options.bind&.split(":")&.last || ENV["BRIDGETOWN_PORT"] || 4000 - bt_options.url = "#{scheme}://localhost:#{port}" - end + bt_options.url = bt_bound_url if Bridgetown.env.development? && !options["url"] - puma_pid = - Process.fork do - require "puma/cli" - - Puma::Runner.class_eval do - def output_header(mode) - log "* Puma version: #{Puma::Const::PUMA_VERSION} (#{ruby_engine}) (\"#{Puma::Const::CODE_NAME}\")" # rubocop:disable Layout/LineLength - if mode == "cluster" - log "* Cluster Master PID: #{Process.pid}" - else - log "* PID: #{Process.pid}" - end - end - end + Bridgetown::Server.new({ + Host: bt_options.bind, + Port: port, + config: "config.ru", + }).tap do |server| + if server.serveable? + create_pid_dir - puma_args = [] - if bt_options[:bind] - puma_args << "--bind" - puma_args << bt_options[:bind] - end + bt_options.skip_live_reload = !server.using_puma? - cli = Puma::CLI.new puma_args - cli.launcher.events.on_stopped do + build_args = ["-w"] + ARGV.reject { |arg| arg == "start" } + build_pid = Process.fork { Bridgetown::Commands::Build.start(build_args) } + add_pid(build_pid, file: :bridgetown) + + after_stop_callback = -> { + say "Stopping Bridgetown server..." Bridgetown::Hooks.trigger :site, :server_shutdown - end - cli.run - end + Process.kill "SIGINT", build_pid + remove_pidfile :bridgetown - begin - Signal.trap("TERM") do - Process.kill "SIGINT", puma_pid - sleep 0.5 # let it breathe - exit 0 # this runs the ensure block below - end + # Shut down the frontend bundler etc. if they're running + unless Bridgetown.env.production? || bt_options[:skip_frontend] + Bridgetown::Utils::Aux.kill_processes + end + } - Process.setproctitle("bridgetown #{Bridgetown::VERSION} [#{File.basename(Dir.pwd)}]") - - build_args = ["-w"] + ARGV.reject { |arg| arg == "start" } - Bridgetown::Commands::Build.start(build_args) - rescue StandardError => e - Process.kill "SIGINT", puma_pid - sleep 0.5 - raise e - ensure - # Shut down esbuild, etc. if they're running - Bridgetown::Utils::Aux.kill_processes - end + Bridgetown.logger.info "" + Bridgetown.logger.info "Booting #{server.name} at:", bt_bound_url.to_s.magenta + Bridgetown.logger.info "" - sleep 0.5 # finish cleaning up + server.start(after_stop_callback) + else + say "Unable to find a Rack server." + end + end end end end diff --git a/bridgetown-core/lib/bridgetown-core/component.rb b/bridgetown-core/lib/bridgetown-core/component.rb index b4dc59ffb..ce8ae0ed5 100644 --- a/bridgetown-core/lib/bridgetown-core/component.rb +++ b/bridgetown-core/lib/bridgetown-core/component.rb @@ -202,7 +202,7 @@ def render_in(view_context, &block) # Subclasses can override this method to return a string from their own # template handling. def template - call || _renderer.render(self) + (method(:call).arity.zero? ? call : nil) || _renderer.render(self) end # Typically not used but here as a compatibility nod toward ViewComponent. diff --git a/bridgetown-core/lib/bridgetown-core/concerns/site/ssr.rb b/bridgetown-core/lib/bridgetown-core/concerns/site/ssr.rb index 4567dc925..d853b4d97 100644 --- a/bridgetown-core/lib/bridgetown-core/concerns/site/ssr.rb +++ b/bridgetown-core/lib/bridgetown-core/concerns/site/ssr.rb @@ -28,12 +28,11 @@ 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 def ssr_setup(&block) - config.serving = true - Bridgetown::Hooks.trigger :site, :pre_read, self ssr_first_read Bridgetown::Hooks.trigger :site, :post_read, self @@ -46,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/configuration.rb b/bridgetown-core/lib/bridgetown-core/configuration.rb index 64a39da8d..f809af6ef 100644 --- a/bridgetown-core/lib/bridgetown-core/configuration.rb +++ b/bridgetown-core/lib/bridgetown-core/configuration.rb @@ -28,69 +28,70 @@ def initialize(*) # Strings rather than symbols are used for compatibility with YAML. DEFAULTS = { # Where things are - "root_dir" => Dir.pwd, - "plugins_dir" => "plugins", - "source" => "src", - "destination" => "output", - "collections_dir" => "", - "cache_dir" => ".bridgetown-cache", - "layouts_dir" => "_layouts", - "components_dir" => "_components", - "islands_dir" => "_islands", - "partials_dir" => "_partials", - "collections" => {}, - "taxonomies" => { + "root_dir" => Dir.pwd, + "plugins_dir" => "plugins", + "source" => "src", + "destination" => "output", + "collections_dir" => "", + "cache_dir" => ".bridgetown-cache", + "layouts_dir" => "_layouts", + "components_dir" => "_components", + "islands_dir" => "_islands", + "partials_dir" => "_partials", + "collections" => {}, + "taxonomies" => { category: { key: "categories", title: "Category" }, tag: { key: "tags", title: "Tag" }, }, - "autoload_paths" => [], - "inflector" => Bridgetown::Inflector.new, - "eager_load_paths" => [], - "autoloader_collapsed_paths" => [], - "additional_watch_paths" => [], + "autoload_paths" => [], + "inflector" => Bridgetown::Inflector.new, + "eager_load_paths" => [], + "autoloader_collapsed_paths" => [], + "additional_watch_paths" => [], # Handling Reading - "include" => [".htaccess", "_redirects", ".well-known"], - "exclude" => [], - "keep_files" => [".git", ".svn", "_bridgetown"], - "encoding" => "utf-8", - "markdown_ext" => "markdown,mkdown,mkdn,mkd,md", - "strict_front_matter" => false, - "slugify_mode" => "pretty", + "include" => [".htaccess", "_redirects", ".well-known"], + "exclude" => [], + "keep_files" => [".git", ".svn", "_bridgetown"], + "encoding" => "utf-8", + "markdown_ext" => "markdown,mkdown,mkdn,mkd,md", + "strict_front_matter" => false, + "slugify_mode" => "pretty", # Filtering Content - "future" => false, - "unpublished" => false, - "ruby_in_front_matter" => true, + "future" => false, + "unpublished" => false, + "ruby_in_front_matter" => true, # Conversion - "content_engine" => "resource", - "markdown" => "kramdown", - "highlighter" => "rouge", + "content_engine" => "resource", + "markdown" => "kramdown", + "highlighter" => "rouge", + "support_data_as_view_methods" => true, # Serving - "port" => "4000", - "host" => "127.0.0.1", - "base_path" => "/", - "show_dir_listing" => false, + "port" => "4000", + "host" => "127.0.0.1", + "base_path" => "/", + "show_dir_listing" => false, # Output Configuration - "available_locales" => [:en], - "default_locale" => :en, - "prefix_default_locale" => false, - "permalink" => nil, # default is set according to content engine - "timezone" => nil, # use the local timezone + "available_locales" => [:en], + "default_locale" => :en, + "prefix_default_locale" => false, + "permalink" => nil, # default is set according to content engine + "timezone" => nil, # use the local timezone - "quiet" => false, - "verbose" => false, - "defaults" => [], + "quiet" => false, + "verbose" => false, + "defaults" => [], - "liquid" => { + "liquid" => { "error_mode" => "warn", "strict_filters" => false, "strict_variables" => false, }, - "kramdown" => { + "kramdown" => { "auto_ids" => true, "toc_levels" => (1..6).to_a, "entity_output" => "as_char", @@ -104,7 +105,7 @@ def initialize(*) "mark_highlighting" => true, }, - "development" => { + "development" => { "fast_refresh" => true, }, }.each_with_object(Configuration.new) { |(k, v), hsh| hsh[k] = v.freeze }.freeze diff --git a/bridgetown-core/lib/bridgetown-core/drops/url_drop.rb b/bridgetown-core/lib/bridgetown-core/drops/url_drop.rb deleted file mode 100644 index 70640022d..000000000 --- a/bridgetown-core/lib/bridgetown-core/drops/url_drop.rb +++ /dev/null @@ -1,152 +0,0 @@ -# frozen_string_literal: true - -module Bridgetown - module Drops - class UrlDrop < Drop - extend Forwardable - - mutable false - - def_delegator :@obj, :cleaned_relative_path, :path - def_delegator :@obj, :output_ext, :output_ext - - def collection - @obj.collection.label - end - - def name - Utils.slugify(@obj.basename_without_ext) - end - - def title - Utils.slugify(qualified_slug_data, mode: "pretty", cased: true) - end - - def slug - Utils.slugify(qualified_slug_data) - end - - def locale - locale_data = @obj.data["locale"] - @obj.site.config["available_locales"].include?(locale_data) ? locale_data : nil - end - alias_method :lang, :locale - - def categories - category_set = Set.new - Array(@obj.data["categories"]).each do |category| - category_set << if @obj.site.config["slugify_categories"] - Utils.slugify(category.to_s) - else - category.to_s.downcase - end - end - category_set.to_a.join("/") - end - - # CCYY - def year - @obj.date.strftime("%Y") - end - - # MM: 01..12 - def month - @obj.date.strftime("%m") - end - - # DD: 01..31 - def day - @obj.date.strftime("%d") - end - - # hh: 00..23 - def hour - @obj.date.strftime("%H") - end - - # mm: 00..59 - def minute - @obj.date.strftime("%M") - end - - # ss: 00..59 - def second - @obj.date.strftime("%S") - end - - # D: 1..31 - def i_day - @obj.date.strftime("%-d") - end - - # M: 1..12 - def i_month - @obj.date.strftime("%-m") - end - - # MMM: Jan..Dec - def short_month - @obj.date.strftime("%b") - end - - # MMMM: January..December - def long_month - @obj.date.strftime("%B") - end - - # YY: 00..99 - def short_year - @obj.date.strftime("%y") - end - - # CCYYw, ISO week year - # may differ from CCYY for the first days of January and last days of December - def w_year - @obj.date.strftime("%G") - end - - # WW: 01..53 - # %W and %U do not comply with ISO 8601-1 - def week - @obj.date.strftime("%V") - end - - # d: 1..7 (Monday..Sunday) - def w_day - @obj.date.strftime("%u") - end - - # dd: Mon..Sun - def short_day - @obj.date.strftime("%a") - end - - # ddd: Monday..Sunday - def long_day - @obj.date.strftime("%A") - end - - # DDD: 001..366 - def y_day - @obj.date.strftime("%j") - end - - private - - def qualified_slug_data - slug_data = @obj.data["slug"] || @obj.basename_without_ext - if @obj.data["locale"] - slug_data.split(".").tap do |segments| - segments.pop if segments.length > 1 && segments.last == @obj.data["locale"] - end.join(".") - else - slug_data - end - end - - def fallback_data - @fallback_data ||= {} - end - end - end -end diff --git a/bridgetown-core/lib/bridgetown-core/front_matter/loaders/ruby.rb b/bridgetown-core/lib/bridgetown-core/front_matter/loaders/ruby.rb index ea07efbc6..7b1da87c5 100644 --- a/bridgetown-core/lib/bridgetown-core/front_matter/loaders/ruby.rb +++ b/bridgetown-core/lib/bridgetown-core/front_matter/loaders/ruby.rb @@ -62,8 +62,8 @@ module Loaders # %}~~~ # ~~~~ class Ruby < Base - HEADER = %r!\A[~`#-]{3,}(?:ruby|<%|{%)\s*\n! - BLOCK = %r!#{HEADER.source}(.*?\n?)^((?:%>|%})?[~`#-]{3,}\s*$\n?)!m + HEADER = %r!\A[~`#-]{3,}(?:ruby|<%|{%)[ \t]*\n! + BLOCK = %r!#{HEADER.source}(.*?\n?)^((?:%>|%})?[~`#-]{3,}[ \t]*$\n?)!m # Determines whether a given file has Ruby front matter # @@ -77,9 +77,9 @@ def self.header?(file) def read(file_contents, file_path:) if (ruby_content = file_contents.match(BLOCK)) && should_execute_inline_ruby? Result.new( - content: ruby_content.post_match, + content: ruby_content.post_match.lstrip, front_matter: process_ruby_data(ruby_content[1], file_path, 2), - line_count: ruby_content[1].lines.size + line_count: ruby_content[1].lines.size - 1 ) elsif self.class.header?(file_path) Result.new( diff --git a/bridgetown-core/lib/bridgetown-core/front_matter/loaders/yaml.rb b/bridgetown-core/lib/bridgetown-core/front_matter/loaders/yaml.rb index 11d639fe8..c915c0798 100644 --- a/bridgetown-core/lib/bridgetown-core/front_matter/loaders/yaml.rb +++ b/bridgetown-core/lib/bridgetown-core/front_matter/loaders/yaml.rb @@ -15,8 +15,8 @@ module Loaders # --- # ~~~ class YAML < Base - HEADER = %r!\A---\s*\n! - BLOCK = %r!\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)!m + HEADER = %r!\A---[ \t]*\n! + BLOCK = %r!#{HEADER.source}(.*?\n?)^((---|\.\.\.)[ \t]*$\n?)!m # Determines whether a given file has YAML front matter # @@ -31,7 +31,7 @@ def read(file_contents, **) yaml_content = file_contents.match(BLOCK) or return Result.new( - content: yaml_content.post_match, + content: yaml_content.post_match.lstrip, front_matter: YAMLParser.load(yaml_content[1]), line_count: yaml_content[1].lines.size - 1 ) diff --git a/bridgetown-core/lib/bridgetown-core/generated_page.rb b/bridgetown-core/lib/bridgetown-core/generated_page.rb index 18030543e..3ef910e9d 100644 --- a/bridgetown-core/lib/bridgetown-core/generated_page.rb +++ b/bridgetown-core/lib/bridgetown-core/generated_page.rb @@ -97,16 +97,30 @@ def permalink data&.permalink end + def add_permalink_suffix(template, permalink_style) + template = template.dup + + case permalink_style + when :pretty, :simple + template << "/" + else + template << "/" if permalink_style.to_s.end_with?("/") + template << ":output_ext" if permalink_style.to_s.end_with?(".*") + end + + template + end + # The template of the permalink. # # @return [String] def template if !html? - "/:path/:basename:output_ext" + "/:dir/:basename:output_ext" elsif index? - "/:path/" + "/:dir/" else - Utils.add_permalink_suffix("/:path/:basename", site.permalink_style) + add_permalink_suffix("/:dir/:basename", site.permalink_style) end end @@ -114,23 +128,25 @@ def template # # @return [String] def url - @url ||= URL.new( - template:, - placeholders: url_placeholders, - permalink: - ).to_s - end - alias_method :relative_url, :url + return @url if @url + + tmpl = permalink || template + placeholders = { dir: @dir, basename:, output_ext: } + + results = placeholders.inject(tmpl) do |result, token| + break result if result.index(":").nil? - # Returns a hash of URL placeholder names (as symbols) mapping to the - # desired placeholder replacements. For details see "url.rb" - def url_placeholders - { - path: @dir, - basename: @basename, - output_ext:, - } + if token.last.nil? + # Remove leading "/" to avoid generating urls with `//` + result.gsub("/:#{token.first}", "") + else + result.gsub(":#{token.first}", token.last) + end + end.then { Addressable::URI.normalize_component _1 } + + @url = "/#{results.sub("#", "%23")}".gsub("..", "/").gsub("./", "").squeeze("/") end + alias_method :relative_url, :url # Layout associated with this resource # This will output a warning if the layout can't be found. @@ -241,7 +257,7 @@ def place_into_layouts # # @return [String] def destination(dest) - path = site.in_dest_dir(dest, URL.unescape_path(url)) + path = site.in_dest_dir(dest, Utils.unencode_uri(url)) path = File.join(path, "index") if url.end_with?("/") path << output_ext unless path.end_with? output_ext path diff --git a/bridgetown-core/lib/bridgetown-core/model/base.rb b/bridgetown-core/lib/bridgetown-core/model/base.rb index ad7edd728..8b56555f8 100644 --- a/bridgetown-core/lib/bridgetown-core/model/base.rb +++ b/bridgetown-core/lib/bridgetown-core/model/base.rb @@ -3,6 +3,8 @@ module Bridgetown module Model class Base + include Bridgetown::RodaCallable + class << self def find(id, site: Bridgetown::Current.site) raise "A Bridgetown site must be initialized and added to Current" unless site @@ -92,6 +94,11 @@ def render_as_resource to_resource.read!.transform! end + # Converts this model to a resource and returns the full output + # + # @return [String] + def call(*) = render_as_resource.output + # override if need be # @return [Bridgetown::Site] def site diff --git a/bridgetown-core/lib/bridgetown-core/model/builder_origin.rb b/bridgetown-core/lib/bridgetown-core/model/builder_origin.rb index 5d0555654..acfe71bf7 100644 --- a/bridgetown-core/lib/bridgetown-core/model/builder_origin.rb +++ b/bridgetown-core/lib/bridgetown-core/model/builder_origin.rb @@ -34,9 +34,11 @@ def read @data end - def exists? - false - end + def front_matter_line_count = @data[:_front_matter_line_count_] + + def original_path = @data[:_original_path_] || relative_path + + def exists? = false def read_data_from_builder builder = Kernel.const_get(url.host.gsub(".", "::")) diff --git a/bridgetown-core/lib/bridgetown-core/rack/boot.rb b/bridgetown-core/lib/bridgetown-core/rack/boot.rb index 126acd124..6c9e7b1a3 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 d656d4ea0..dcc5448fc 100644 --- a/bridgetown-core/lib/bridgetown-core/rack/routes.rb +++ b/bridgetown-core/lib/bridgetown-core/rack/routes.rb @@ -28,7 +28,7 @@ def print_routes end puts puts "Routes:" - puts "=======" + puts "=======\n" if routes.blank? puts "No routes found. Have you commented all of your routes?" puts "Documentation: https://github.com/jeremyevans/roda-route_list#basic-usage-" @@ -88,36 +88,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/bridgetown-core/resource/base.rb b/bridgetown-core/lib/bridgetown-core/resource/base.rb index 986d9eec0..4a9bb1e41 100644 --- a/bridgetown-core/lib/bridgetown-core/resource/base.rb +++ b/bridgetown-core/lib/bridgetown-core/resource/base.rb @@ -5,6 +5,7 @@ module Resource class Base # rubocop:todo Metrics/ClassLength using Bridgetown::Refinements include Comparable + include Bridgetown::RodaCallable include Bridgetown::Publishable include Bridgetown::LayoutPlaceable include Bridgetown::LiquidRenderable @@ -169,6 +170,11 @@ def transform! # rubocop:todo Metrics/CyclomaticComplexity self end + # Transforms the resource and returns the full output + # + # @return [String] + def call(*) = transform!.output + def trigger_hooks(hook_name, *args) Bridgetown::Hooks.trigger collection.label.to_sym, hook_name, self, *args if collection Bridgetown::Hooks.trigger :resources, hook_name, self, *args diff --git a/bridgetown-core/lib/bridgetown-core/resource/destination.rb b/bridgetown-core/lib/bridgetown-core/resource/destination.rb index 4f7f273da..d89f15d01 100644 --- a/bridgetown-core/lib/bridgetown-core/resource/destination.rb +++ b/bridgetown-core/lib/bridgetown-core/resource/destination.rb @@ -32,7 +32,7 @@ def final_ext end def output_path - path = URL.unescape_path(relative_url) + path = Utils.unencode_uri(relative_url) if resource.site.base_path.present? path = path.delete_prefix resource.site.base_path(strip_slash_only: true) end diff --git a/bridgetown-core/lib/bridgetown-core/ruby_template_view.rb b/bridgetown-core/lib/bridgetown-core/ruby_template_view.rb index ab4b50a63..36d453914 100644 --- a/bridgetown-core/lib/bridgetown-core/ruby_template_view.rb +++ b/bridgetown-core/lib/bridgetown-core/ruby_template_view.rb @@ -39,15 +39,16 @@ def initialize(convertible) end @paginator = resource.paginator if resource.respond_to?(:paginator) @site = resource.site + @support_data_as_view_methods = @site.config.support_data_as_view_methods end - def data - resource.data - end + def data = resource.data - def partial(_partial_name = nil, **_options) - raise "Must be implemented in a subclass" - end + def collections = site.collections + + def site_drop = site.site_payload.site + + def partial(_partial_name = nil, **_options) = raise("Must be implemented in a subclass") def render(item, **options, &) if item.respond_to?(:render_in) @@ -58,14 +59,6 @@ def render(item, **options, &) end end - def collections - site.collections - end - - def site_drop - site.site_payload.site - end - def liquid_render(component, options = {}, &block) options[:_block_content] = capture(&block) if block && respond_to?(:capture) render_statement = _render_statement(component, options) @@ -84,9 +77,17 @@ def helpers @helpers ||= Helpers.new(self, site) end + def data_key?(key, *args, **kwargs) + return false unless @support_data_as_view_methods + + args.empty? && kwargs.empty? && !block_given? && data.key?(key) + end + def method_missing(method_name, ...) if helpers.respond_to?(method_name.to_sym) helpers.send(method_name.to_sym, ...) + elsif data_key?(method_name, ...) + data[method_name] else super end @@ -96,9 +97,7 @@ def respond_to_missing?(method_name, include_private = false) helpers.respond_to?(method_name.to_sym, include_private) || super end - def inspect - "#<#{self.class} layout=#{layout&.label} resource=#{resource.relative_path}>" - end + def inspect = "#<#{self.class} layout=#{layout&.label} resource=#{resource.relative_path}>" private diff --git a/bridgetown-core/lib/bridgetown-core/static_file.rb b/bridgetown-core/lib/bridgetown-core/static_file.rb index a425c89f5..e3b0d4aa9 100644 --- a/bridgetown-core/lib/bridgetown-core/static_file.rb +++ b/bridgetown-core/lib/bridgetown-core/static_file.rb @@ -59,7 +59,7 @@ def destination(dest) if site.base_path.present? && collection dest_url = dest_url.delete_prefix site.base_path(strip_slash_only: true) end - site.in_dest_dir(dest, Bridgetown::URL.unescape_path(dest_url)) + site.in_dest_dir(dest, Bridgetown::Utils.unencode_uri(dest_url)) end def destination_rel_dir diff --git a/bridgetown-core/lib/bridgetown-core/url.rb b/bridgetown-core/lib/bridgetown-core/url.rb deleted file mode 100644 index 25cc10b05..000000000 --- a/bridgetown-core/lib/bridgetown-core/url.rb +++ /dev/null @@ -1,166 +0,0 @@ -# frozen_string_literal: true - -# Public: Methods that generate a URL for GeneratedPage. -# -# Examples -# -# URL.new({ -# :template => /:categories/:title.html", -# :placeholders => {:categories => "ruby", :title => "something"} -# }).to_s -# -module Bridgetown - # TODO: remove this class in favor of the new Resource permalink processor - class URL - # options - One of :permalink or :template must be supplied. - # :template - The String used as template for URL generation, - # for example "/:path/:basename:output_ext", where - # a placeholder is prefixed with a colon. - # :placeholders - A hash containing the placeholders which will be - # replaced when used inside the template. E.g. - # { "year" => Time.now.strftime("%Y") } would replace - # the placeholder ":year" with the current year. - # :permalink - If supplied, no URL will be generated from the - # template. Instead, the given permalink will be - # used as URL. - def initialize(options) - @template = options[:template] - @placeholders = options[:placeholders] || {} - @permalink = options[:permalink] - - return unless (@template || @permalink).nil? - - raise ArgumentError, "One of :template or :permalink must be supplied." - end - - # The generated relative URL of the resource - # - # Returns the String URL - def to_s - sanitize_url(generated_permalink || generated_url) - end - - # Generates a URL from the permalink - # - # Returns the _unsanitized String URL - def generated_permalink - (@generated_permalink ||= generate_url(@permalink)) if @permalink - end - - # Generates a URL from the template - # - # Returns the unsanitized String URL - def generated_url - @generated_url ||= generate_url(@template) - end - - # Internal: Generate the URL by replacing all placeholders with their - # respective values in the given template - # - # Returns the unsanitized String URL - def generate_url(template) - if @placeholders.is_a? Drops::UrlDrop - generate_url_from_drop(template) - else - generate_url_from_hash(template) - end - end - - def generate_url_from_hash(template) - @placeholders.inject(template) do |result, token| - break result if result.index(":").nil? - - if token.last.nil? - # Remove leading "/" to avoid generating urls with `//` - result.gsub("/:#{token.first}", "") - else - result.gsub(":#{token.first}", self.class.escape_path(token.last)) - end - end - end - - # We include underscores in keys to allow for 'i_month' and so forth. - # This poses a problem for keys which are followed by an underscore - # but the underscore is not part of the key, e.g. '/:month_:day'. - # That should be :month and :day, but our key extraction regexp isn't - # smart enough to know that so we have to make it an explicit - # possibility. - def possible_keys(key) - if key.end_with?("_") - [key, key.chomp("_")] - else - [key] - end - end - - def generate_url_from_drop(template) - template.gsub(%r!:([a-z_]+)!) do |match| - pool = possible_keys(match.sub(":", "")) - - winner = pool.find { |key| @placeholders.key?(key) } - if winner.nil? - raise NoMethodError, - "The URL template doesn't have #{pool.join(" or ")} keys. " \ - "Check your permalink template!" - end - - value = @placeholders[winner] - value = "" if value.nil? - replacement = self.class.escape_path(value) - - match.sub(":#{winner}", replacement) - end.squeeze("/") - end - - # Returns a sanitized String URL, stripping "../../" and multiples of "/", - # as well as the beginning "/" so we can enforce and ensure it. - - def sanitize_url(str) - "/#{str}".gsub("..", "/").gsub("./", "").squeeze("/") - end - - # Escapes a path to be a valid URL path segment - # - # path - The path to be escaped. - # - # Examples: - # - # URL.escape_path("/a b") - # # => "/a%20b" - # - # Returns the escaped path. - def self.escape_path(path) - path = path.to_s - return path if path.empty? || %r!^[a-zA-Z0-9./-]+$!.match?(path) - - # Because URI.escape doesn't escape "?", "[" and "]" by default, - # specify unsafe string (except unreserved, sub-delims, ":", "@" and "/"). - # - # URI path segment is defined in RFC 3986 as follows: - # segment = *pchar - # pchar = unreserved / pct-encoded / sub-delims / ":" / "@" - # unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" - # pct-encoded = "%" HEXDIG HEXDIG - # sub-delims = "!" / "$" / "&" / "'" / "(" / ")" - # / "*" / "+" / "," / ";" / "=" - Addressable::URI.encode(path).encode("utf-8").sub("#", "%23") - end - - # Unescapes a URL path segment - # - # path - The path to be unescaped. - # - # Examples: - # - # URL.unescape_path("/a%20b") - # # => "/a b" - # - # Returns the unescaped path. - def self.unescape_path(path) - path = path.encode("utf-8") - return path unless path.include?("%") - - Addressable::URI.unencode(path) - end - end -end diff --git a/bridgetown-core/lib/bridgetown-core/utils.rb b/bridgetown-core/lib/bridgetown-core/utils.rb index 849d5fe8e..74452c352 100644 --- a/bridgetown-core/lib/bridgetown-core/utils.rb +++ b/bridgetown-core/lib/bridgetown-core/utils.rb @@ -8,6 +8,7 @@ module Utils # rubocop:todo Metrics/ModuleLength autoload :RequireGems, "bridgetown-core/utils/require_gems" autoload :RubyExec, "bridgetown-core/utils/ruby_exec" autoload :SmartyPantsConverter, "bridgetown-core/utils/smarty_pants_converter" + autoload :PidTracker, "bridgetown-core/utils/pid_tracker" # Constants for use in #slugify SLUGIFY_MODES = %w(raw default pretty simple ascii latin).freeze @@ -35,6 +36,13 @@ def xml_escape(input) input.to_s.encode(xml: :attr).gsub(%r!\A"|"\Z!, "") end + def unencode_uri(path) + path = path.encode("utf-8") + return path unless path.include?("%") + + Addressable::URI.unencode(path) + end + # Non-destructive version of deep_merge_hashes! See that method. # # Returns the merged hashes. @@ -213,49 +221,6 @@ def slugify(string, mode: nil, cased: false) slug end - # Add an appropriate suffix to template so that it matches the specified - # permalink style. - # - # template - permalink template without trailing slash or file extension - # permalink_style - permalink style, either built-in or custom - # - # The returned permalink template will use the same ending style as - # specified in permalink_style. For example, if permalink_style contains a - # trailing slash (or is :pretty, which indirectly has a trailing slash), - # then so will the returned template. If permalink_style has a trailing - # ":output_ext" (or is :none, :date, or :ordinal) then so will the returned - # template. Otherwise, template will be returned without modification. - # - # Examples: - # add_permalink_suffix("/:basename", :pretty) - # # => "/:basename/" - # - # add_permalink_suffix("/:basename", :date) - # # => "/:basename:output_ext" - # - # add_permalink_suffix("/:basename", "/:year/:month/:title/") - # # => "/:basename/" - # - # add_permalink_suffix("/:basename", "/:year/:month/:title") - # # => "/:basename" - # - # Returns the updated permalink template - def add_permalink_suffix(template, permalink_style) - template = template.dup - - case permalink_style - when :pretty, :simple - template << "/" - when :date, :ordinal, :none - template << ":output_ext" - else - template << "/" if permalink_style.to_s.end_with?("/") - template << ":output_ext" if permalink_style.to_s.end_with?(":output_ext") - end - - template - end - # Work the same way as Dir.glob but seperating the input into two parts # ('dir' + '/' + 'pattern') to make sure the first part('dir') does not act # as a pattern. diff --git a/bridgetown-core/lib/bridgetown-core/utils/aux.rb b/bridgetown-core/lib/bridgetown-core/utils/aux.rb index 02a795c9b..864183f42 100644 --- a/bridgetown-core/lib/bridgetown-core/utils/aux.rb +++ b/bridgetown-core/lib/bridgetown-core/utils/aux.rb @@ -3,20 +3,14 @@ module Bridgetown module Utils module Aux + extend Bridgetown::Utils::PidTracker + def self.with_color(name, message) return message unless !name.nil? && Bridgetown::Foundation::Ansi::COLORS[name.to_sym] Bridgetown::Foundation::Ansi.send(name, message) end - def self.running_pids - @running_pids ||= [] - end - - def self.add_pid(pid) - running_pids << pid - end - def self.run_process(name, color, cmd, env: {}) @mutex ||= Thread::Mutex.new @@ -24,7 +18,7 @@ def self.run_process(name, color, cmd, env: {}) rd, wr = IO.pipe("BINARY") pid = Process.spawn({ "BRIDGETOWN_NO_BUNDLER_REQUIRE" => nil }.merge(env), cmd, out: wr, err: wr, pgroup: true) - @mutex.synchronize { add_pid(pid) } + @mutex.synchronize { add_pid(pid, file: :aux) } loop do line = rd.gets @@ -45,9 +39,12 @@ def self.run_process(name, color, cmd, env: {}) def self.kill_processes Bridgetown.logger.info "Stopping auxiliary processes..." - running_pids.each do |pid| - Process.kill("SIGTERM", -Process.getpgid(pid)) + + read_pidfile(:aux).each do |pid| + Process.kill("SIGTERM", -Process.getpgid(pid.to_i)) rescue Errno::ESRCH, Errno::EPERM, Errno::ECHILD # rubocop:disable Lint/SuppressedException + ensure + remove_pidfile :aux end end end diff --git a/bridgetown-core/lib/bridgetown-core/utils/pid_tracker.rb b/bridgetown-core/lib/bridgetown-core/utils/pid_tracker.rb new file mode 100644 index 000000000..067bab453 --- /dev/null +++ b/bridgetown-core/lib/bridgetown-core/utils/pid_tracker.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Bridgetown + module Utils + module PidTracker + def create_pid_dir + FileUtils.mkdir_p pids_dir + end + + def add_pid(pid, file:) + File.write pidfile_for(file), "#{pid}\n", mode: "a+" + end + + def read_pidfile(file) + File.readlines pidfile_for(file), chomp: true + end + + def remove_pidfile(file) + File.delete pidfile_for(file) + end + + private + + def root_dir + Dir.pwd + end + + def pids_dir + File.join(root_dir, "tmp", "pids") + end + + def pidfile_for(file) + File.join(pids_dir, "#{file}.pid") + end + end + end +end diff --git a/bridgetown-core/lib/bridgetown-core/watcher.rb b/bridgetown-core/lib/bridgetown-core/watcher.rb index cccc9d387..4f9438c1d 100644 --- a/bridgetown-core/lib/bridgetown-core/watcher.rb +++ b/bridgetown-core/lib/bridgetown-core/watcher.rb @@ -25,11 +25,11 @@ def watch(site, options, &block) Bridgetown::Hooks.trigger :site, :post_read, site block&.call(site) end - end - Bridgetown.logger.info "Watcher:", "enabled." unless options[:using_puma] + return + end - return if options[:serving] + Bridgetown.logger.info "Watcher:", "enabled." unless options[:start_command] trap("INT") do self.shutdown = true diff --git a/bridgetown-core/lib/roda/plugins/bridgetown_server.rb b/bridgetown-core/lib/roda/plugins/bridgetown_server.rb index 222c30898..15e08df4a 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")) @@ -40,17 +41,7 @@ def self.load_dependencies(app) # rubocop:disable Metrics "500 Internal Server Error" end - # This lets us return models or resources directly in Roda response blocks - app.plugin :custom_block_results - - app.handle_block_result Bridgetown::Model::Base do |result| - result.render_as_resource.output - end - - app.handle_block_result Bridgetown::Resource::Base do |result| - 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 +98,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 +116,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 @@ -137,9 +147,26 @@ def cookies HashWithDotAccess::Hash.new(_previous_roda_cookies) 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/bridgetown_ssr.rb b/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb index 3bf5a5779..97141c48a 100644 --- a/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb +++ b/bridgetown-core/lib/roda/plugins/bridgetown_ssr.rb @@ -9,6 +9,17 @@ module InstanceMethods def bridgetown_site self.class.opts[:bridgetown_site] end + + alias_method :site, :bridgetown_site + end + + def self.load_dependencies(app) + app.plugin :custom_block_results + + # This lets us return callable objects directly in Roda response blocks + app.handle_block_result(Bridgetown::RodaCallable) do |callable| + callable.(self) + end end def self.configure(app, _opts = {}, &) 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 39a713e44..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 @@ -32,3 +31,12 @@ yarn-debug.log* .pnp.js # Yarn Integrity file .yarn-integrity + +# Ignore all tempfiles. +/tmp/* +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep diff --git a/bridgetown-core/lib/site_template/config/puma.rb b/bridgetown-core/lib/site_template/config/puma.rb index 7bb953ed5..e93a86b68 100644 --- a/bridgetown-core/lib/site_template/config/puma.rb +++ b/bridgetown-core/lib/site_template/config/puma.rb @@ -19,6 +19,8 @@ min_threads_count = ENV.fetch("BRIDGETOWN_MIN_THREADS") { max_threads_count } threads min_threads_count, max_threads_count +pidfile ENV["PIDFILE"] || "tmp/pids/server.pid" + # Preload the application for maximum performance # preload_app! diff --git a/bridgetown-core/lib/site_template/tmp/pids/.keep b/bridgetown-core/lib/site_template/tmp/pids/.keep new file mode 100644 index 000000000..e69de29bb diff --git a/bridgetown-core/test/resources/src/_layouts/default.erb b/bridgetown-core/test/resources/src/_layouts/default.erb index dddb4afb1..15c376a2b 100644 --- a/bridgetown-core/test/resources/src/_layouts/default.erb +++ b/bridgetown-core/test/resources/src/_layouts/default.erb @@ -2,7 +2,9 @@ --- - + + <%= title %> + <%= yield %> diff --git a/bridgetown-core/test/resources/src/_pages/second-level-page.en.md b/bridgetown-core/test/resources/src/_pages/second-level-page.en.md index 0d95ecc70..82c302256 100644 --- a/bridgetown-core/test/resources/src/_pages/second-level-page.en.md +++ b/bridgetown-core/test/resources/src/_pages/second-level-page.en.md @@ -1,5 +1,5 @@ ~~~ruby -{ title: "I'm a Second Level Page" } +{ title: "I'm a Second Level Page", layout: :default } ~~~ That's **nice**. diff --git a/bridgetown-core/test/resources/src/_pages/second-level-page.fr.md b/bridgetown-core/test/resources/src/_pages/second-level-page.fr.md index f6f4ae319..39ffe8101 100644 --- a/bridgetown-core/test/resources/src/_pages/second-level-page.fr.md +++ b/bridgetown-core/test/resources/src/_pages/second-level-page.fr.md @@ -1,5 +1,5 @@ ~~~ruby -{ title: "I'm a Second Level Page in French" } +{ title: "I'm a Second Level Page in French", layout: :default } ~~~ C'est **bien**. diff --git a/bridgetown-core/test/ssr/config.ru b/bridgetown-core/test/ssr/config.ru index 957991226..aaee05741 100644 --- a/bridgetown-core/test/ssr/config.ru +++ b/bridgetown-core/test/ssr/config.ru @@ -10,4 +10,6 @@ require "bridgetown-core/rack/boot" Bridgetown::Rack.boot +require_relative "src/_components/UseRoda" # 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/hello.rb b/bridgetown-core/test/ssr/server/routes/hello.rb index b737e655b..edebce028 100644 --- a/bridgetown-core/test/ssr/server/routes/hello.rb +++ b/bridgetown-core/test/ssr/server/routes/hello.rb @@ -4,7 +4,7 @@ class Routes::Hello < Bridgetown::Rack::Routes priority :lowest route do |r| - saved_value = bridgetown_site.data.save_value + saved_value = site.data.save_value # route: GET /hello/:name r.get "hello", String do |name| diff --git a/bridgetown-core/test/ssr/server/routes/render_resource.rb b/bridgetown-core/test/ssr/server/routes/render_resource.rb index 07798b00e..4a49e27a2 100644 --- a/bridgetown-core/test/ssr/server/routes/render_resource.rb +++ b/bridgetown-core/test/ssr/server/routes/render_resource.rb @@ -5,12 +5,16 @@ class Routes::RenderResource < Bridgetown::Rack::Routes # route: GET /render_resource r.get "render_resource" do # Roda should know how to autorender the resource - bridgetown_site.collections.pages.resources.find { _1.id == "repo://pages.collection/index.md" } + site.collections.pages.find { _1.id == "repo://pages.collection/index.md" } end r.get "render_model" do # Roda should know how to autorender the model as a resource Bridgetown::Model::Base.find("repo://pages.collection/test_doc.md") end + + r.get "render_component", String do |title| + UseRoda.new(title:) + end end end diff --git a/bridgetown-core/test/ssr/src/_components/UseRoda.rb b/bridgetown-core/test/ssr/src/_components/UseRoda.rb new file mode 100644 index 000000000..14dd7fb84 --- /dev/null +++ b/bridgetown-core/test/ssr/src/_components/UseRoda.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class UseRoda < Bridgetown::Component + include Bridgetown::RodaCallable + + def initialize(title:) # rubocop:disable Lint/MissingSuper + @title = title.upcase + end + + def template + "#{@title} #{@testing}" # not real RSS =) + end + + def call(app) + app.request => r + @testing = r.env["rack.test"] + + app.response["Content-Type"] = "application/rss+xml" + + render_in(app) + end +end diff --git a/bridgetown-core/test/test_generated_page.rb b/bridgetown-core/test/test_generated_page.rb index 11749091d..0c83d0bbe 100644 --- a/bridgetown-core/test/test_generated_page.rb +++ b/bridgetown-core/test/test_generated_page.rb @@ -236,25 +236,6 @@ def render_and_write end end - context "with date permalink style" do - setup do - @site.permalink_style = :date - end - - should "return url and destination correctly" do - @page = setup_page("contacts.html") - @dest_file = dest_dir("contacts.html") - assert_equal "/contacts.html", @page.url - assert_equal @dest_file, @page.destination(dest_dir) - end - - should "return dir correctly" do - assert_equal "/", setup_page("contacts.html").dir - assert_equal "/", setup_page("contacts/bar.html").dir - assert_equal "/", setup_page("contacts/index.html").dir - end - end - context "with custom permalink style with trailing slash" do setup do @site.permalink_style = "/:title/" @@ -270,7 +251,7 @@ def render_and_write context "with custom permalink style with file extension" do setup do - @site.permalink_style = "/:title:output_ext" + @site.permalink_style = "/:title.*" end should "return URL and destination correctly" do diff --git a/bridgetown-core/test/test_locales.rb b/bridgetown-core/test/test_locales.rb index 76b7ffa1d..cf1f275ab 100644 --- a/bridgetown-core/test/test_locales.rb +++ b/bridgetown-core/test/test_locales.rb @@ -24,11 +24,13 @@ class TestLocales < BridgetownUnitTest should "have the correct permalink and locale in English" do assert_equal "/second-level-page/", @english_resource.relative_url + assert_includes @english_resource.output, "I'm a Second Level Page" assert_includes @english_resource.output, "

Locale: en

" end should "have the correct permalink and locale in French" do assert_equal "/fr/second-level-page/", @french_resource.relative_url + assert_includes @french_resource.output, "I'm a Second Level Page in French" assert_includes @french_resource.output, "

C’est bien.

\n\n

Locale: fr

" assert_includes @french_resource.output, <<-HTML diff --git a/bridgetown-core/test/test_ssr.rb b/bridgetown-core/test/test_ssr.rb index 36fd247e3..69fe97447 100644 --- a/bridgetown-core/test/test_ssr.rb +++ b/bridgetown-core/test/test_ssr.rb @@ -85,5 +85,13 @@ def site assert last_response.ok? assert_includes last_response.body, "

THIS IS A TEST.

" end + + should "return rendered component" do + get "/render_component/wow" + + assert last_response.ok? + assert_equal "application/rss+xml", last_response["Content-Type"] + assert_equal "WOW true", last_response.body + end end end diff --git a/bridgetown-core/test/test_utils.rb b/bridgetown-core/test/test_utils.rb index 3f5c05fbc..f07071f32 100644 --- a/bridgetown-core/test/test_utils.rb +++ b/bridgetown-core/test/test_utils.rb @@ -308,46 +308,6 @@ class TestUtils < BridgetownUnitTest end end - context "The `Utils.add_permalink_suffix` method" do - should "handle built-in permalink styles" do - assert_equal( - "/:basename/", - Utils.add_permalink_suffix("/:basename", :pretty) - ) - assert_equal( - "/:basename/", - Utils.add_permalink_suffix("/:basename", :simple) - ) - assert_equal( - "/:basename:output_ext", - Utils.add_permalink_suffix("/:basename", :date) - ) - assert_equal( - "/:basename:output_ext", - Utils.add_permalink_suffix("/:basename", :ordinal) - ) - assert_equal( - "/:basename:output_ext", - Utils.add_permalink_suffix("/:basename", :none) - ) - end - - should "handle custom permalink styles" do - assert_equal( - "/:basename/", - Utils.add_permalink_suffix("/:basename", "/:title/") - ) - assert_equal( - "/:basename:output_ext", - Utils.add_permalink_suffix("/:basename", "/:title:output_ext") - ) - assert_equal( - "/:basename", - Utils.add_permalink_suffix("/:basename", "/:title") - ) - end - end - context "The `Utils.safe_glob` method" do should "not apply pattern to the dir" do dir = "test/safe_glob_test[" diff --git a/bridgetown-paginate/lib/bridgetown-paginate/pagination_page.rb b/bridgetown-paginate/lib/bridgetown-paginate/pagination_page.rb index 88a52ff89..4e0603ed3 100644 --- a/bridgetown-paginate/lib/bridgetown-paginate/pagination_page.rb +++ b/bridgetown-paginate/lib/bridgetown-paginate/pagination_page.rb @@ -65,7 +65,9 @@ def set_url(url_value) def destination(dest) path = site.in_dest_dir( - dest, URL.unescape_path(url).delete_prefix(site.base_path(strip_slash_only: true)) + dest, Bridgetown::Utils + .unencode_uri(url) + .delete_prefix(site.base_path(strip_slash_only: true)) ) path = File.join(path, "index") if url.end_with?("/") path << output_ext unless path.end_with? output_ext diff --git a/bridgetown-routes/.rubocop.yml b/bridgetown-routes/.rubocop.yml index 1ff53ae14..adcbd262d 100644 --- a/bridgetown-routes/.rubocop.yml +++ b/bridgetown-routes/.rubocop.yml @@ -4,4 +4,4 @@ inherit_from: ../.rubocop.yml AllCops: Exclude: - "*.gemspec" - - "test/ssr/src/_islands/**/*.rb" + - "test/ssr/src/**/*.rb" diff --git a/bridgetown-routes/lib/bridgetown-routes/code_blocks.rb b/bridgetown-routes/lib/bridgetown-routes/code_blocks.rb index e02210fc0..e3c3f1fe3 100644 --- a/bridgetown-routes/lib/bridgetown-routes/code_blocks.rb +++ b/bridgetown-routes/lib/bridgetown-routes/code_blocks.rb @@ -6,8 +6,11 @@ module CodeBlocks class << self attr_accessor :blocks - def add_route(name, file_code = nil, &block) + def add_route(name, file_code = nil, front_matter_line_count = nil, &block) block.instance_variable_set(:@_route_file_code, file_code) if file_code + if front_matter_line_count + block.instance_variable_set(:@_front_matter_line_count, front_matter_line_count) + end @blocks ||= {} @blocks[name] = block @@ -31,17 +34,22 @@ def eval_route_file(file, file_slug, app) # rubocop:disable Lint/UnusedMethodArg code = File.read(file) code_postmatch = nil ruby_content = code.match(Bridgetown::FrontMatter::Loaders::Ruby::BLOCK) + front_matter_line_count = nil if ruby_content code = ruby_content[1] - code_postmatch = ruby_content.post_match + code_postmatch = ruby_content.post_match.lstrip + front_matter_line_count = code.lines.count - 1 + if code.match?(%r!^\s*render_with(\s|\()!).! && code.match?(%r!r\.[a-z]+\s+do!).! + code.concat("\nrender_with {}") + end end - code = <<~RUBY - add_route(#{file_slug.inspect}, #{code_postmatch.inspect}) do |r| - #{code} - end - RUBY - instance_eval(code, file, ruby_content ? 1 : 0) + # rubocop:disable Style/DocumentDynamicEvalDefinition, Style/EvalWithLocation + code_proc = Kernel.eval( + "proc {|r| #{code} }", TOPLEVEL_BINDING, file, ruby_content ? 2 : 1 + ) + add_route(file_slug, code_postmatch, front_matter_line_count, &code_proc) + # rubocop:enable Style/DocumentDynamicEvalDefinition, Style/EvalWithLocation end end end 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..223a96adf 100644 --- a/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb +++ b/bridgetown-routes/lib/roda/plugins/bridgetown_routes.rb @@ -25,16 +25,56 @@ 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 + response.instance_variable_set( + :@_front_matter_line_count, + route_block.instance_variable_get(:@_front_matter_line_count) + ) # 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) + denylisted = %i(r argv) data = b.local_variables.filter_map do |key| next if denylisted.any? key @@ -63,7 +103,9 @@ def render_with(data: {}, &) # rubocop:todo Metrics/AbcSize, Metrics/MethodLengt ) ).read do data[:_collection_] = bridgetown_site.collections.pages + data[:_original_path_] = path data[:_relative_path_] = source_path + data[:_front_matter_line_count_] = response._front_matter_line_count data[:_content_] = code data end @@ -85,11 +127,38 @@ 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 - @_route_file_code - end + def _route_file_code = @_route_file_code + + # we need to know where the real template starts for good error reporting + def _front_matter_line_count = @_front_matter_line_count def _fake_resource_view(view_class:, roda_app:, bridgetown_site:) @_fake_resource_views ||= {} diff --git a/bridgetown-routes/test/ssr/src/_routes/bare_route.rb b/bridgetown-routes/test/ssr/src/_routes/bare_route.rb new file mode 100644 index 000000000..ed363b1ca --- /dev/null +++ b/bridgetown-routes/test/ssr/src/_routes/bare_route.rb @@ -0,0 +1,18 @@ +module DoubleNumbers + def double + self * 2 + end +end +Numeric.include DoubleNumbers + +class DoublingArray < Array + def double_map + map(&:double) + end +end + +r.get Integer do |num| + numbers = DoublingArray.new([1, 2, 3, num]).double_map + + { numbers: } +end diff --git a/bridgetown-routes/test/ssr/src/_routes/howdy.erb b/bridgetown-routes/test/ssr/src/_routes/howdy.erb index 06bb8feab..6926a96bc 100644 --- a/bridgetown-routes/test/ssr/src/_routes/howdy.erb +++ b/bridgetown-routes/test/ssr/src/_routes/howdy.erb @@ -1,7 +1,7 @@ ---<% -render_with data: { +render_with(data: { title: "#{params[:yo]} #{@answer_to_life}" -} +}) %>---

<%= resource.data.title %>

diff --git a/bridgetown-routes/test/ssr/src/_routes/localized.erb b/bridgetown-routes/test/ssr/src/_routes/localized.erb index e9b12a4e2..677448617 100644 --- a/bridgetown-routes/test/ssr/src/_routes/localized.erb +++ b/bridgetown-routes/test/ssr/src/_routes/localized.erb @@ -4,4 +4,4 @@ render_with do end %>--- -

<%= data.title %> for <%= r.params[:locale] %> - <%= I18n.locale %>

+

<%= title %> for <%= r.params[:locale] %> - <%= I18n.locale %>

diff --git a/bridgetown-routes/test/ssr/src/_routes/nested/[slug].erb b/bridgetown-routes/test/ssr/src/_routes/nested/[slug].erb index 99c204f01..bf4a047d7 100644 --- a/bridgetown-routes/test/ssr/src/_routes/nested/[slug].erb +++ b/bridgetown-routes/test/ssr/src/_routes/nested/[slug].erb @@ -1,8 +1,7 @@ ---<% slug_param = params[:slug] +() # test for previous regexp bug title = "Nested Page with Slug" - -render_with {} %>--- -

<%= data.title %>: <%= data.slug_param %>

+

<%= title %>: <%= slug_param %>

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..cfaee70c3 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? @@ -57,6 +66,11 @@ def site assert_equal "

Nested Page with Slug: 123-abc

\n", last_response.body end + should "return JSON for a base route (no template)" do + get "/bare_route/4" + assert_equal({ numbers: [2, 4, 6, 8] }.to_json, last_response.body) + end + should "return the proper route within an island" do get "/paradise" do assert_equal "Living in paradise =)", last_response.body diff --git a/bridgetown-website/Gemfile.lock b/bridgetown-website/Gemfile.lock index 81c0e532c..031e6246a 100644 --- a/bridgetown-website/Gemfile.lock +++ b/bridgetown-website/Gemfile.lock @@ -23,6 +23,7 @@ PATH liquid (>= 5.0, < 5.5) listen (~> 3.0) rack (>= 3.0) + rackup (~> 2.0) rake (>= 13.0) roda (~> 3.46) rouge (>= 3.0, < 5.0) @@ -124,9 +125,12 @@ GEM public_suffix (5.0.5) puma (6.4.2) nio4r (~> 2.0) - racc (1.7.3) - rack (3.0.10) - rake (13.2.1) + racc (1.7.1) + rack (3.0.9.1) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) + rake (13.0.6) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) @@ -150,8 +154,8 @@ GEM tilt (2.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - uri (0.13.0) - zeitwerk (2.6.13) + webrick (1.8.1) + zeitwerk (2.6.11) PLATFORMS arm64-darwin-22 diff --git a/bridgetown-website/src/_docs/command-line-usage.md b/bridgetown-website/src/_docs/command-line-usage.md index b1dd08a9f..ac09db0fa 100644 --- a/bridgetown-website/src/_docs/command-line-usage.md +++ b/bridgetown-website/src/_docs/command-line-usage.md @@ -84,7 +84,7 @@ As is shown in comments for the default Rakefile, you can add your own [automati task :my_task => :environment do puts site.root_dir automation do - say_status :rake, "I'm a Rake tast =) #{site.config.url}" + say_status :rake, "I'm a Rake task =) #{site.config.url}" end end ``` diff --git a/bridgetown-website/src/_posts/2024/2024-05-01-road-to-bridgetown-2.0-fast-refresh.md b/bridgetown-website/src/_posts/2024/2024-05-01-road-to-bridgetown-2.0-fast-refresh.md new file mode 100644 index 000000000..a9ca091fe --- /dev/null +++ b/bridgetown-website/src/_posts/2024/2024-05-01-road-to-bridgetown-2.0-fast-refresh.md @@ -0,0 +1,68 @@ +--- +date: Wednesday, May 1, 2024 at 9:44:34 AM PDT +title: Road to Bridgetown 2.0, Part 3 (Fast Refresh) +subtitle: Saving a file often regenerates so quickly that by time you switch back to your browser, it's already been refreshed. Cool! +author: jared +category: future +--- + +So before I get right into it, I'm happy to report that **Bridgetown 2.0 development progress is proceeding at a rapid pace!** Many of the features talked about in the previous rounds ([here](https://www.bridgetownrb.com/future/road-to-bridgetown-2.0-escaping-burnout/) and [here](https://www.bridgetownrb.com/future/road-to-bridgetown-2.0-new-baselines/)) are well underway, alongside some significant quality of life DX improvements which will make this release really sizzle. Plus I'm looking forward to blogging about some of the underlying particulars soon at the recently rebooted [Fullstack Ruby](https://www.fullstackruby.dev). + +Right, now to the topic at hand. I'll get this out of the way: the **Fast Refresh** feature—a default setting for the development server coming in v2.0—is _not_ like [HMR](https://vitejs.dev/guide/features.html#hot-module-replacement) (Hot Module Replacement), a popular strategy for JavaScript frameworks to make reloading changed code speedy during development. This is in part because—aside from any actual JavsScript files you may have for your frontend—Bridgetown doesn't use JavaScript. + +Bridgetown uses Ruby, and to be precise, is based on "old-school" principles of static site generation. (Unless we're talking about dynamic routes served via Roda…we'll save that for a future discussion!) The way it works is you have a repo with a **wide variety of input files**—Markdown, CSV, HTML templates, images, other assets—and a **build process transforms all of those input files** in a variety of ways and then outputs them in formats suitable for a functioning website. In that sense, to "reload" a site after making a file change means to go through that _entire build process again_. For a small site, a full rebuild might be relatively quick…or it might be quite slow if you have **hundreds or thousands of pages and assets** to deal with. + +### Where We've Been + +Bridgetown's progenitor, Jekyll, offers a limited scope of understanding around the types of content & code to rebuild on-demand as it doesn't come with any frontend pipeline and doesn't provide any "live reload" functionality at all for the browser—nor will Jekyll reload Ruby extensions in a repo when you change that code. However, what Jekyll does have—as do many SSGs out there—that Bridgetown hasn't had to date is an optional **"incremental" regeneration process**—that is, a change to a content file (Markdown, etc.) doesn't necessarily require rebuilding the entire site from scratch. But even that can come with [limitations](https://jekyllrb.com/docs/configuration/incremental-regeneration/), and in many cases a change to a file doesn't trigger the neccessary downstream changes elsewhere—aka you might revise a headline in Markdown file over here, and over there a template which references said headline would still display the old content. + +**Stuff like that really grinds my gears.** It's why Bridgetown hasn't offered an incremental regeneration feature or fast refresh or whatever you want to call it. **Trust is the issue.** I want to feel confident that the content I'm viewing in development is as _accurate_ as possible, and to a certain degree, you can't ever trust that what you're seeing is actually correct when anything less that a _full, from-scratch rebuild_ has occured. + +Nevertheless, it's admittedly a serious UX fail when sites get larger and larger and you suddenly realize that when you **fix a typo in a Markdown file** you now have to wait **8 seconds** before you see that fix appear in the browser. _Unacceptable!_ In an ideal world, you wouldn't have to wait 8 seconds. Hopefully you wouldn't even need to wait 800 milliseconds. The refresh would occur as close to "instantaneously" as possible. + +**That's the goal with Fast Refresh in Bridgetown 2.0.** + +How did we accomplish this feat? Read on… + +### Signals (Of Course 😏) + +The concept of [Signals has taken the frontend world by storm](https://www.spicyweb.dev/videos/2024/signals-are-eating-the-web/), and that shift has started to ripple outward into other computing contexts as well. So what are signals? In a nutshell, **signals** are _reactive variables_—aka values which, when mutated, cause all subscribers to be notified. If you're familar with the simpler pattern of observables, you know you have to set up subscriptions by hand—a tedious and sometimes error-prone endeavor. Signals instead are regularly paired with **effects**—functions which will automatically subscribe to any signals referenced within the function when executed. Later, whenever those signal values change, the effect functions re-execute—_like magic!_ ✨ + +For a deep dive into this topic from the Ruby perspective, check out [Episode 9 of Fullstack Ruby](https://www.fullstackruby.dev/podcast/8/). TL;DR: thanks to the [Signalize gem](https://github.com/whitefusionhq/signalize) which I wrote as a direct port of Preact Signals, we can use signals in Ruby. And the reason this is such a game-changer for Bridgetown? + +By placing **resource data into signals**, and **transformation steps inside of effects**, we can track via effects which resources or generated pages would need to be updated due to signals changing. In other words, during the initial full build, we're assembling a _dependency graph_ in real-time of which pages should be rebuilt later. That way during a refresh, instead of a simplistic incremental regeneration acting on one piece of source data and leaving that data stale on other parts of the site—or just doing the full rebuild which can take a long time—we can instead only rebuild 5 interdependent pages, or 10 pages, or even 50 pages…but probably not 200! (Plus we also get to skip a lot of other slow code reloading logic and so forth whenever it's simply not necessary…which is the majority of the time!) + +### The Devil's in the Details + +This process is fairly straightforward if the changed file in question is indeed a resource. We can build up the resource (which could be a page at a URL or it could be a data file) + dependency graph, and simply regenerate those resources. But things get tricky when "generated pages" are involved such as using prototype pages or pagination. For those cases, we need to backtrack to an original resource and re-extract all the necessary data for the generated pages which follow. + +All of the places where reactive data can end up are vital to the integrity of the Fast Refresh process. Think of all the contexts where content cohesion is crucial: + +* If you **change the name of a person** in `_data/authors.yml`, all of their blog posts should update. +* If you **change the title of a document**, a sidebar with a list of those documents should update whichever pages include that sidebar. +* If you **update a blog post description**, "page 4" in an archive somewhere should update with that new description. + +Doing all that is pretty challenging if you have to trace all those dependencies by hand (either under the hood with complex automagical logic, or with specific directives users must understand and maintain themselves…eww!). + +**Thankfully…signals to the rescue.** And we're not simply tracking resource<->resource connections, but connections between templates and rendered components. If I update a single component template, but that template is only referenced by one or a few resources (or layouts used by those resources), why should the entire site get rebuilt? Let's just rebuild the resources which directly render that component. Even layouts factor into this: if you edit a layout, only the resources which use that layout will be regenerated. + +For the most part, Fast Refresh will require no changes to existing site repos. It'll "just work". But we do have a new mechanism in particular for handling site-wide data which can prove quite interesting. Instead of reaching for `site.data`, reach for `site.signals`. All of the keys will be shortcuts for setting/getting signal values—aka `site.signals.authors` is shorthand for `site.signals.authors_signal.value` and `site.signals.authors.value = ...` is shortchand for `site.signals.authors_signal.value = ...`. This means you can save and access site data throughout various plugins/templates, and any changes made to data files will propagate accordingly during Fast Refresh. + +All of this serves to ensure that when you update a file, it's often rebuilt so quickly that by the time you switch from your editor back over to the site in the browser, _it's already been refreshed._ (We also have increased speed overall thanks to revisions to our Rack/Roda integration!) I've been experiencing this rapid round-tripping a lot over the past few weeks, and it's pretty freakin' cool. 😎⚡️ + + +### Escape Hatch + +The version of Fast Refresh shipping in Bridgetown 2.0 will be good, but it won't be perfect. There are times it may get tangled up in the web of its own dependencies, or fail to account for a particular type of change, and you'll need to reboot the dev server—or in the worst case, temporarily switch off fast refresh in your config. + +**Fast Refresh will get top priority for bug fixes for the forseeable future**, which is one of the reasons we're releasing it switched on by default. We _want_ as many people as possible to test this right out of the gate, so we can fix edge cases as quickly as possible. My own experience has been that even with an occasional hiccup, **the quality of life improvement with the increased refresh speed more than makes up for those annoyances**. Most of the time, _it rocks._ + +We'll also be shipping a bonus feature: a way for you to hook into Bridgetown's live reload JavaScript process to control what happens for that browser reload. For users of frontend libraries like Swup, htmx, Turbo, etc. which can swap or even morph page DOM as part of navigation, you could use those to pull in the updated HTML for an _even slicker experience_. 😎 + +### Performance is a Feature Too + +One of the goals of Bridgetown 2.0 (and 2.1 and beyond) is to reframe how we look at opportunities to increase framework performance. There's never been a desire among the core team to shave a few ms off of a synthetic benchmark, or to gain paltry bits of performance at the expense of great DX. + +But if we can identify clear wins around simplifying code steps, creating modular configurations, streamlining algorithms, and encouraging certain architectures over others so as to improve the performance of both static generation and dynamic routes meaningfully, we're ready to dive in. If you would like to contribute a test site we can use to benchmark Bridgetown 1.x vs 2.x as we fine-tune this release, please get in touch! Our hope is to gradually build up our release QA process to include regression testing…aka a full site build with each new Bridgetown release should be the same or faster, _definitely_ not slower. + +OK, that does it for Fast Refresh! **Stay tuned for the next installment of the "Road to Bridgetown 2.0" series** all about where we're going with our **Roda** and **Sequel** integrations. _Spoiler alert:_ Bridgetown 2.0 will completely support Rack-native, fullstack, database-driven application requirements where even your _index_ file can be a dynamic route if you so choose. Have your static website cake _and_ eat your dynamic server too? 🍰 **Yep!** 😁 \ No newline at end of file