# frozen_string_literal: true module Bridgetown module Watcher extend self class << self attr_accessor :shutdown end # Continuously watch for file changes and rebuild the site whenever a change is detected. # # @param site [Bridgetown::Site] the current site instance # @param options [Bridgetown::Configuration] the site configuration # @yield the block will be called when in SSR mode right after the post_read event def watch(site, options, &block) ENV["LISTEN_GEM_DEBUGGING"] ||= "1" if options["verbose"] listen(site, options) if site.ssr? # We need to trigger pre/post read hooks when SSR reload occurs in order to re-run Builders Bridgetown::Hooks.register_one :site, :after_soft_reset, reloadable: false do Bridgetown::Hooks.trigger :site, :pre_read, site Bridgetown::Hooks.trigger :site, :post_read, site block&.call(site) end return end Bridgetown.logger.info "Watcher:", "enabled." unless options[:start_command] trap("INT") do self.shutdown = true end sleep_forever end # Return a list of load paths which should be watched for changes # # @param (see #watch) def load_paths_to_watch(site, options) additional_paths = options.additional_watch_paths [ *site.plugin_manager.plugins_path, *options.autoload_paths, *additional_paths, ].uniq.select do |path| Dir.exist?(path) end end # Start a listener to watch for changes and call {#reload_site} # # @param (see #watch) def listen(site, options) # rubocop:disable Metrics/MethodLength bundling_path = site.frontend_bundling_path FileUtils.mkdir_p(bundling_path) Listen.to( options["source"], bundling_path, *load_paths_to_watch(site, options), ignore: listen_ignore_paths(options), force_polling: options["force_polling"] ) do |modified, added, removed| c = modified + added + removed # NOTE: inexplicably, this matcher doesn't work with the Listen gem, so # we have to run it here manually c.reject! { component_frontend_matcher(options).match? _1 } n = c.length next if n.zero? unless site.ssr? Bridgetown.logger.info( "Reloading…", "#{n} file#{"s" if n > 1} changed at #{Time.now.strftime("%Y-%m-%d %H:%M:%S")}" ) c.each { |path| Bridgetown.logger.info "", "- #{path["#{site.root_dir}/".length..]}" } end reload_site( site, options, paths: c, fast_refreshable: site.config.fast_refresh && (added + removed).empty? ) end.start end # Reload the site including plugins and Zeitwerk autoloaders and process it (unless SSR) # # @param site [Bridgetown::Site] the current site instance # @param options [Bridgetown::Configuration] the site configuration # @param paths Array def reload_site(site, options, paths: [], fast_refreshable: false) # rubocop:todo Metrics/MethodLength begin time = Time.now I18n.reload! # make sure any locale files get read again Bridgetown::Current.sites[site.label] = site # needed in SSR mode apparently catch :halt do unless fast_refreshable Bridgetown::Hooks.trigger :site, :pre_reload, site, paths Bridgetown::Hooks.clear_reloadable_hooks site.loaders_manager.reload_loaders Bridgetown::Hooks.trigger :site, :post_reload, site, paths end if site.ssr? site.reset(soft: true) return end if fast_refreshable site.fast_refresh(paths, reload_if_needed: true) else site.process end end Bridgetown.logger.info "Done! 🎉", "#{"Completed".bold.green} in less than " \ "#{(Time.now - time).ceil(2)} seconds." rescue StandardError, SyntaxError => e Bridgetown::Errors.print_build_error(e, trace: options[:trace]) end Bridgetown.logger.info "" end private def normalize_encoding(obj, desired_encoding) case obj when Array obj.map { |entry| entry.encode!(desired_encoding, entry.encoding) } when String obj.encode!(desired_encoding, obj.encoding) end end def custom_excludes(options) Array(options["exclude"]).map { |e| Bridgetown.sanitized_path(options["root_dir"], e) } end # rubocop:disable Layout/LineLength def component_frontend_matcher(options) # TODO: maybe a negative lookbehind would make this regex cleaner? @fematcher ||= %r{(#{options[:components_dir]}|#{options[:islands_dir]})/(?:[^.]+|\.(?!dsd))+(\.js|\.jsx|\.js\.rb|\.css)$} end # rubocop:enable Layout/LineLength def to_exclude(options) [ options["destination"], custom_excludes(options), ].flatten end # Paths to ignore for the watch option # # @param options [Bridgetown::Configuration] options loaded from config and/or CLI # @return [Array] list of relative paths from source that should be ignored def listen_ignore_paths(options) source = Pathname.new(options["source"]).expand_path paths = to_exclude(options) paths.filter_map do |p| absolute_path = Pathname.new(normalize_encoding(p, options["source"].encoding)).expand_path next unless absolute_path.exist? begin relative_path = absolute_path.relative_path_from(source).to_s relative_path = File.join(relative_path, "") if absolute_path.directory? unless relative_path.start_with?("../") path_to_ignore = %r!^#{Regexp.escape(relative_path)}! Bridgetown.logger.debug "Watcher:", "Ignoring #{path_to_ignore}" path_to_ignore end rescue ArgumentError # Could not find a relative path end end end def sleep_forever sleep 0.5 until shutdown end end end