From 9d68b1b134a50ee2ba670920b32b0f72f7af88f7 Mon Sep 17 00:00:00 2001 From: Alex Wood Date: Wed, 6 Dec 2017 16:33:51 -0500 Subject: [PATCH] Add LiveReload functionality to Jekyll. (#5142) Merge pull request 5142 --- Gemfile | 1 + jekyll.gemspec | 1 + lib/jekyll/commands/serve.rb | 179 ++- .../commands/serve/live_reload_reactor.rb | 153 +++ .../serve/livereload_assets/livereload.js | 1183 +++++++++++++++++ lib/jekyll/commands/serve/servlet.rb | 137 ++ lib/jekyll/commands/serve/websockets.rb | 80 ++ lib/jekyll/utils.rb | 1 + lib/jekyll/utils/thread_event.rb | 35 + test/test_commands_serve.rb | 125 ++ 10 files changed, 1875 insertions(+), 20 deletions(-) create mode 100644 lib/jekyll/commands/serve/live_reload_reactor.rb create mode 100644 lib/jekyll/commands/serve/livereload_assets/livereload.js create mode 100644 lib/jekyll/commands/serve/websockets.rb create mode 100644 lib/jekyll/utils/thread_event.rb diff --git a/Gemfile b/Gemfile index 68bc28c2..f7000acb 100644 --- a/Gemfile +++ b/Gemfile @@ -24,6 +24,7 @@ end group :test do gem "codeclimate-test-reporter", "~> 1.0.5" gem "cucumber", RUBY_VERSION >= "2.2" ? "~> 3.0" : "3.0.1" + gem "httpclient" gem "jekyll_test_plugin" gem "jekyll_test_plugin_malicious" # nokogiri v1.8 does not work with ruby 2.1 and below diff --git a/jekyll.gemspec b/jekyll.gemspec index 90ad0ceb..b434881c 100644 --- a/jekyll.gemspec +++ b/jekyll.gemspec @@ -32,6 +32,7 @@ Gem::Specification.new do |s| s.add_runtime_dependency("addressable", "~> 2.4") s.add_runtime_dependency("colorator", "~> 1.0") + s.add_runtime_dependency("em-websocket", "~> 0.5") s.add_runtime_dependency("i18n", "~> 0.7") s.add_runtime_dependency("jekyll-sass-converter", "~> 1.0") s.add_runtime_dependency("jekyll-watch", "~> 2.0") diff --git a/lib/jekyll/commands/serve.rb b/lib/jekyll/commands/serve.rb index 9861d2c6..50438b5f 100644 --- a/lib/jekyll/commands/serve.rb +++ b/lib/jekyll/commands/serve.rb @@ -1,20 +1,43 @@ # frozen_string_literal: true +require "thread" + module Jekyll module Commands class Serve < Command + # Similar to the pattern in Utils::ThreadEvent except we are maintaining the + # state of @running instead of just signaling an event. We have to maintain this + # state since Serve is just called via class methods instead of an instance + # being created each time. + @mutex = Mutex.new + @run_cond = ConditionVariable.new + @running = false + class << self COMMAND_OPTIONS = { - "ssl_cert" => ["--ssl-cert [CERT]", "X.509 (SSL) certificate."], - "host" => ["host", "-H", "--host [HOST]", "Host to bind to"], - "open_url" => ["-o", "--open-url", "Launch your site in a browser"], - "detach" => ["-B", "--detach", "Run the server in the background"], - "ssl_key" => ["--ssl-key [KEY]", "X.509 (SSL) Private Key."], - "port" => ["-P", "--port [PORT]", "Port to listen on"], - "show_dir_listing" => ["--show-dir-listing", + "ssl_cert" => ["--ssl-cert [CERT]", "X.509 (SSL) certificate."], + "host" => ["host", "-H", "--host [HOST]", "Host to bind to"], + "open_url" => ["-o", "--open-url", "Launch your site in a browser"], + "detach" => ["-B", "--detach", + "Run the server in the background",], + "ssl_key" => ["--ssl-key [KEY]", "X.509 (SSL) Private Key."], + "port" => ["-P", "--port [PORT]", "Port to listen on"], + "show_dir_listing" => ["--show-dir-listing", "Show a directory listing instead of loading your index file.",], - "skip_initial_build" => ["skip_initial_build", "--skip-initial-build", + "skip_initial_build" => ["skip_initial_build", "--skip-initial-build", "Skips the initial site build which occurs before the server is started.",], + "livereload" => ["-l", "--livereload", + "Use LiveReload to automatically refresh browsers",], + "livereload_ignore" => ["--livereload-ignore ignore GLOB1[,GLOB2[,...]]", + Array, + "Files for LiveReload to ignore. Remember to quote the values so your shell "\ + "won't expand them",], + "livereload_min_delay" => ["--livereload-min-delay [SECONDS]", + "Minimum reload delay",], + "livereload_max_delay" => ["--livereload-max-delay [SECONDS]", + "Maximum reload delay",], + "livereload_port" => ["--livereload-port [PORT]", Integer, + "Port for LiveReload to listen on",], }.freeze DIRECTORY_INDEX = %w( @@ -26,7 +49,11 @@ module Jekyll index.json ).freeze - # + LIVERELOAD_PORT = 35_729 + LIVERELOAD_DIR = File.join(__dir__, "serve", "livereload_assets") + + attr_reader :mutex, :run_cond, :running + alias_method :running?, :running def init_with_program(prog) prog.command(:serve) do |cmd| @@ -41,20 +68,34 @@ module Jekyll end cmd.action do |_, opts| + opts["livereload_port"] ||= LIVERELOAD_PORT opts["serving"] = true opts["watch" ] = true unless opts.key?("watch") - config = configuration_from_options(opts) - if Jekyll.env == "development" - config["url"] = default_url(config) - end - [Build, Serve].each { |klass| klass.process(config) } + start(opts) end end end # + def start(opts) + # Set the reactor to nil so any old reactor will be GCed. + # We can't unregister a hook so in testing when Serve.start is + # called multiple times we don't want to inadvertently keep using + # a reactor created by a previous test when our test might not + @reload_reactor = nil + + register_reload_hooks(opts) if opts["livereload"] + config = configuration_from_options(opts) + if Jekyll.env == "development" + config["url"] = default_url(config) + end + [Build, Serve].each { |klass| klass.process(config) } + end + + # + def process(opts) opts = configuration_from_options(opts) destination = opts["destination"] @@ -63,6 +104,76 @@ module Jekyll start_up_webrick(opts, destination) end + def shutdown + @server.shutdown if running? + end + + # Perform logical validation of CLI options + + private + def validate_options(opts) + if opts["livereload"] + if opts["detach"] + Jekyll.logger.warn "Warning:", + "--detach and --livereload are mutually exclusive. Choosing --livereload" + opts["detach"] = false + end + if opts["ssl_cert"] || opts["ssl_key"] + # This is not technically true. LiveReload works fine over SSL, but + # EventMachine's SSL support in Windows requires building the gem's + # native extensions against OpenSSL and that proved to be a process + # so tedious that expecting users to do it is a non-starter. + Jekyll.logger.abort_with "Error:", "LiveReload does not support SSL" + end + unless opts["watch"] + # Using livereload logically implies you want to watch the files + opts["watch"] = true + end + elsif %w(livereload_min_delay + livereload_max_delay + livereload_ignore + livereload_port).any? { |o| opts[o] } + Jekyll.logger.abort_with "--livereload-min-delay, "\ + "--livereload-max-delay, --livereload-ignore, and "\ + "--livereload-port require the --livereload option." + end + end + + # + + private + # rubocop:disable Metrics/AbcSize + def register_reload_hooks(opts) + require_relative "serve/live_reload_reactor" + @reload_reactor = LiveReloadReactor.new + + Jekyll::Hooks.register(:site, :post_render) do |site| + regenerator = Jekyll::Regenerator.new(site) + @changed_pages = site.pages.select do |p| + regenerator.regenerate?(p) + end + end + + # A note on ignoring files: LiveReload errs on the side of reloading when it + # comes to the message it gets. If, for example, a page is ignored but a CSS + # file linked in the page isn't, the page will still be reloaded if the CSS + # file is contained in the message sent to LiveReload. Additionally, the + # path matching is very loose so that a message to reload "/" will always + # lead the page to reload since every page starts with "/". + Jekyll::Hooks.register(:site, :post_write) do + if @changed_pages && @reload_reactor && @reload_reactor.running? + ignore, @changed_pages = @changed_pages.partition do |p| + Array(opts["livereload_ignore"]).any? do |filter| + File.fnmatch(filter, Jekyll.sanitized_path(p.relative_path)) + end + end + Jekyll.logger.debug "LiveReload:", "Ignoring #{ignore.map(&:relative_path)}" + @reload_reactor.reload(@changed_pages) + end + @changed_pages = nil + end + end + # Do a base pre-setup of WEBRick so that everything is in place # when we get ready to party, checking for an setting up an error page # and making sure our destination exists. @@ -92,6 +203,7 @@ module Jekyll :MimeTypes => mime_types, :DocumentRoot => opts["destination"], :StartCallback => start_callback(opts["detach"]), + :StopCallback => stop_callback(opts["detach"]), :BindAddress => opts["host"], :Port => opts["port"], :DirectoryIndex => DIRECTORY_INDEX, @@ -108,11 +220,16 @@ module Jekyll private def start_up_webrick(opts, destination) - server = WEBrick::HTTPServer.new(webrick_opts(opts)).tap { |o| o.unmount("") } - server.mount(opts["baseurl"].to_s, Servlet, destination, file_handler_opts) - Jekyll.logger.info "Server address:", server_address(server, opts) - launch_browser server, opts if opts["open_url"] - boot_or_detach server, opts + if opts["livereload"] + @reload_reactor.start(opts) + end + + @server = WEBrick::HTTPServer.new(webrick_opts(opts)).tap { |o| o.unmount("") } + @server.mount(opts["baseurl"].to_s, Servlet, destination, file_handler_opts) + + Jekyll.logger.info "Server address:", server_address(@server, opts) + launch_browser @server, opts if opts["open_url"] + boot_or_detach @server, opts end # Recreate NondisclosureName under utf-8 circumstance @@ -227,7 +344,29 @@ module Jekyll def start_callback(detached) unless detached proc do - Jekyll.logger.info("Server running...", "press ctrl-c to stop.") + mutex.synchronize do + # Block until EventMachine reactor starts + @reload_reactor.started_event.wait unless @reload_reactor.nil? + @running = true + Jekyll.logger.info("Server running...", "press ctrl-c to stop.") + @run_cond.broadcast + end + end + end + end + + private + def stop_callback(detached) + unless detached + proc do + mutex.synchronize do + unless @reload_reactor.nil? + @reload_reactor.stop + @reload_reactor.stopped_event.wait + end + @running = false + @run_cond.broadcast + end end end end diff --git a/lib/jekyll/commands/serve/live_reload_reactor.rb b/lib/jekyll/commands/serve/live_reload_reactor.rb new file mode 100644 index 00000000..498e78ae --- /dev/null +++ b/lib/jekyll/commands/serve/live_reload_reactor.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "json" +require "em-websocket" + +require_relative "websockets" + +module Jekyll + module Commands + class Serve + class LiveReloadReactor + attr_reader :started_event + attr_reader :stopped_event + attr_reader :thread + + def initialize + @thread = nil + @websockets = [] + @connections_count = 0 + @started_event = Utils::ThreadEvent.new + @stopped_event = Utils::ThreadEvent.new + end + + def stop + # There is only one EventMachine instance per Ruby process so stopping + # it here will stop the reactor thread we have running. + EM.stop if EM.reactor_running? + Jekyll.logger.debug("LiveReload Server:", "halted") + end + + def running? + EM.reactor_running? + end + + def handle_websockets_event(ws) + ws.onopen do |handshake| + connect(ws, handshake) + end + + ws.onclose do + disconnect(ws) + end + + ws.onmessage do |msg| + print_message(msg) + end + + ws.onerror do |error| + log_error(error) + end + end + + # rubocop:disable Metrics/MethodLength + def start(opts) + @thread = Thread.new do + # Use epoll if the kernel supports it + EM.epoll + EM.run do + EM.error_handler do |e| + log_error(e) + end + + EM.start_server( + opts["host"], + opts["livereload_port"], + HttpAwareConnection, + opts + ) do |ws| + handle_websockets_event(ws) + end + + # Notify blocked threads that EventMachine has started or shutdown + EM.schedule do + @started_event.set + end + + EM.add_shutdown_hook do + @stopped_event.set + end + + Jekyll.logger.info( + "LiveReload address:", "#{opts["host"]}:#{opts["livereload_port"]}" + ) + end + end + @thread.abort_on_exception = true + end + + # For a description of the protocol see + # http://feedback.livereload.com/knowledgebase/articles/86174-livereload-protocol + def reload(pages) + pages.each do |p| + msg = { + :command => "reload", + :path => p.url, + :liveCSS => true, + } + + Jekyll.logger.debug("LiveReload:", "Reloading #{p.url}") + Jekyll.logger.debug(JSON.dump(msg)) + @websockets.each do |ws| + ws.send(JSON.dump(msg)) + end + end + end + + private + def connect(ws, handshake) + @connections_count += 1 + if @connections_count == 1 + message = "Browser connected" + message += " over SSL/TLS" if handshake.secure? + Jekyll.logger.info("LiveReload:", message) + end + ws.send( + JSON.dump( + :command => "hello", + :protocols => ["http://livereload.com/protocols/official-7"], + :serverName => "jekyll" + ) + ) + + @websockets << ws + end + + private + def disconnect(ws) + @websockets.delete(ws) + end + + private + def print_message(json_message) + msg = JSON.parse(json_message) + # Not sure what the 'url' command even does in LiveReload. The spec is silent + # on its purpose. + if msg["command"] == "url" + Jekyll.logger.info("LiveReload:", "Browser URL: #{msg["url"]}") + end + end + + private + def log_error(e) + Jekyll.logger.warn( + "LiveReload experienced an error. "\ + "Run with --verbose for more information." + ) + Jekyll.logger.debug("LiveReload Error:", e.message) + Jekyll.logger.debug("LiveReload Error:", e.backtrace.join("\n")) + end + end + end + end +end diff --git a/lib/jekyll/commands/serve/livereload_assets/livereload.js b/lib/jekyll/commands/serve/livereload_assets/livereload.js new file mode 100644 index 00000000..eee60ec8 --- /dev/null +++ b/lib/jekyll/commands/serve/livereload_assets/livereload.js @@ -0,0 +1,1183 @@ +(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o tag"); + return; + } + } + this.reloader = new Reloader(this.window, this.console, Timer); + this.connector = new Connector(this.options, this.WebSocket, Timer, { + connecting: (function(_this) { + return function() {}; + })(this), + socketConnected: (function(_this) { + return function() {}; + })(this), + connected: (function(_this) { + return function(protocol) { + var _base; + if (typeof (_base = _this.listeners).connect === "function") { + _base.connect(); + } + _this.log("LiveReload is connected to " + _this.options.host + ":" + _this.options.port + " (protocol v" + protocol + ")."); + return _this.analyze(); + }; + })(this), + error: (function(_this) { + return function(e) { + if (e instanceof ProtocolError) { + if (typeof console !== "undefined" && console !== null) { + return console.log("" + e.message + "."); + } + } else { + if (typeof console !== "undefined" && console !== null) { + return console.log("LiveReload internal error: " + e.message); + } + } + }; + })(this), + disconnected: (function(_this) { + return function(reason, nextDelay) { + var _base; + if (typeof (_base = _this.listeners).disconnect === "function") { + _base.disconnect(); + } + switch (reason) { + case 'cannot-connect': + return _this.log("LiveReload cannot connect to " + _this.options.host + ":" + _this.options.port + ", will retry in " + nextDelay + " sec."); + case 'broken': + return _this.log("LiveReload disconnected from " + _this.options.host + ":" + _this.options.port + ", reconnecting in " + nextDelay + " sec."); + case 'handshake-timeout': + return _this.log("LiveReload cannot connect to " + _this.options.host + ":" + _this.options.port + " (handshake timeout), will retry in " + nextDelay + " sec."); + case 'handshake-failed': + return _this.log("LiveReload cannot connect to " + _this.options.host + ":" + _this.options.port + " (handshake failed), will retry in " + nextDelay + " sec."); + case 'manual': + break; + case 'error': + break; + default: + return _this.log("LiveReload disconnected from " + _this.options.host + ":" + _this.options.port + " (" + reason + "), reconnecting in " + nextDelay + " sec."); + } + }; + })(this), + message: (function(_this) { + return function(message) { + switch (message.command) { + case 'reload': + return _this.performReload(message); + case 'alert': + return _this.performAlert(message); + } + }; + })(this) + }); + this.initialized = true; + } + + LiveReload.prototype.on = function(eventName, handler) { + return this.listeners[eventName] = handler; + }; + + LiveReload.prototype.log = function(message) { + return this.console.log("" + message); + }; + + LiveReload.prototype.performReload = function(message) { + var _ref, _ref1; + this.log("LiveReload received reload request: " + (JSON.stringify(message, null, 2))); + return this.reloader.reload(message.path, { + liveCSS: (_ref = message.liveCSS) != null ? _ref : true, + liveImg: (_ref1 = message.liveImg) != null ? _ref1 : true, + originalPath: message.originalPath || '', + overrideURL: message.overrideURL || '', + serverURL: "http://" + this.options.host + ":" + this.options.port + }); + }; + + LiveReload.prototype.performAlert = function(message) { + return alert(message.message); + }; + + LiveReload.prototype.shutDown = function() { + var _base; + if (!this.initialized) { + return; + } + this.connector.disconnect(); + this.log("LiveReload disconnected."); + return typeof (_base = this.listeners).shutdown === "function" ? _base.shutdown() : void 0; + }; + + LiveReload.prototype.hasPlugin = function(identifier) { + return !!this.pluginIdentifiers[identifier]; + }; + + LiveReload.prototype.addPlugin = function(pluginClass) { + var plugin; + if (!this.initialized) { + return; + } + if (this.hasPlugin(pluginClass.identifier)) { + return; + } + this.pluginIdentifiers[pluginClass.identifier] = true; + plugin = new pluginClass(this.window, { + _livereload: this, + _reloader: this.reloader, + _connector: this.connector, + console: this.console, + Timer: Timer, + generateCacheBustUrl: (function(_this) { + return function(url) { + return _this.reloader.generateCacheBustUrl(url); + }; + })(this) + }); + this.plugins.push(plugin); + this.reloader.addPlugin(plugin); + }; + + LiveReload.prototype.analyze = function() { + var plugin, pluginData, pluginsData, _i, _len, _ref; + if (!this.initialized) { + return; + } + if (!(this.connector.protocol >= 7)) { + return; + } + pluginsData = {}; + _ref = this.plugins; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + plugin = _ref[_i]; + pluginsData[plugin.constructor.identifier] = pluginData = (typeof plugin.analyze === "function" ? plugin.analyze() : void 0) || {}; + pluginData.version = plugin.constructor.version; + } + this.connector.sendCommand({ + command: 'info', + plugins: pluginsData, + url: this.window.location.href + }); + }; + + return LiveReload; + + })(); + +}).call(this); + +},{"./connector":1,"./options":5,"./reloader":7,"./timer":9}],5:[function(require,module,exports){ +(function() { + var Options; + + exports.Options = Options = (function() { + function Options() { + this.https = false; + this.host = null; + this.port = 35729; + this.snipver = null; + this.ext = null; + this.extver = null; + this.mindelay = 1000; + this.maxdelay = 60000; + this.handshake_timeout = 5000; + } + + Options.prototype.set = function(name, value) { + if (typeof value === 'undefined') { + return; + } + if (!isNaN(+value)) { + value = +value; + } + return this[name] = value; + }; + + return Options; + + })(); + + Options.extract = function(document) { + var element, keyAndValue, m, mm, options, pair, src, _i, _j, _len, _len1, _ref, _ref1; + _ref = document.getElementsByTagName('script'); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + element = _ref[_i]; + if ((src = element.src) && (m = src.match(/^[^:]+:\/\/(.*)\/z?livereload\.js(?:\?(.*))?$/))) { + options = new Options(); + options.https = src.indexOf("https") === 0; + if (mm = m[1].match(/^([^\/:]+)(?::(\d+))?$/)) { + options.host = mm[1]; + if (mm[2]) { + options.port = parseInt(mm[2], 10); + } + } + if (m[2]) { + _ref1 = m[2].split('&'); + for (_j = 0, _len1 = _ref1.length; _j < _len1; _j++) { + pair = _ref1[_j]; + if ((keyAndValue = pair.split('=')).length > 1) { + options.set(keyAndValue[0].replace(/-/g, '_'), keyAndValue.slice(1).join('=')); + } + } + } + return options; + } + } + return null; + }; + +}).call(this); + +},{}],6:[function(require,module,exports){ +(function() { + var PROTOCOL_6, PROTOCOL_7, Parser, ProtocolError, + __indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; }; + + exports.PROTOCOL_6 = PROTOCOL_6 = 'http://livereload.com/protocols/official-6'; + + exports.PROTOCOL_7 = PROTOCOL_7 = 'http://livereload.com/protocols/official-7'; + + exports.ProtocolError = ProtocolError = (function() { + function ProtocolError(reason, data) { + this.message = "LiveReload protocol error (" + reason + ") after receiving data: \"" + data + "\"."; + } + + return ProtocolError; + + })(); + + exports.Parser = Parser = (function() { + function Parser(handlers) { + this.handlers = handlers; + this.reset(); + } + + Parser.prototype.reset = function() { + return this.protocol = null; + }; + + Parser.prototype.process = function(data) { + var command, e, message, options, _ref; + try { + if (this.protocol == null) { + if (data.match(/^!!ver:([\d.]+)$/)) { + this.protocol = 6; + } else if (message = this._parseMessage(data, ['hello'])) { + if (!message.protocols.length) { + throw new ProtocolError("no protocols specified in handshake message"); + } else if (__indexOf.call(message.protocols, PROTOCOL_7) >= 0) { + this.protocol = 7; + } else if (__indexOf.call(message.protocols, PROTOCOL_6) >= 0) { + this.protocol = 6; + } else { + throw new ProtocolError("no supported protocols found"); + } + } + return this.handlers.connected(this.protocol); + } else if (this.protocol === 6) { + message = JSON.parse(data); + if (!message.length) { + throw new ProtocolError("protocol 6 messages must be arrays"); + } + command = message[0], options = message[1]; + if (command !== 'refresh') { + throw new ProtocolError("unknown protocol 6 command"); + } + return this.handlers.message({ + command: 'reload', + path: options.path, + liveCSS: (_ref = options.apply_css_live) != null ? _ref : true + }); + } else { + message = this._parseMessage(data, ['reload', 'alert']); + return this.handlers.message(message); + } + } catch (_error) { + e = _error; + if (e instanceof ProtocolError) { + return this.handlers.error(e); + } else { + throw e; + } + } + }; + + Parser.prototype._parseMessage = function(data, validCommands) { + var e, message, _ref; + try { + message = JSON.parse(data); + } catch (_error) { + e = _error; + throw new ProtocolError('unparsable JSON', data); + } + if (!message.command) { + throw new ProtocolError('missing "command" key', data); + } + if (_ref = message.command, __indexOf.call(validCommands, _ref) < 0) { + throw new ProtocolError("invalid command '" + message.command + "', only valid commands are: " + (validCommands.join(', ')) + ")", data); + } + return message; + }; + + return Parser; + + })(); + +}).call(this); + +},{}],7:[function(require,module,exports){ +(function() { + var IMAGE_STYLES, Reloader, numberOfMatchingSegments, pathFromUrl, pathsMatch, pickBestMatch, splitUrl; + + splitUrl = function(url) { + var hash, index, params; + if ((index = url.indexOf('#')) >= 0) { + hash = url.slice(index); + url = url.slice(0, index); + } else { + hash = ''; + } + if ((index = url.indexOf('?')) >= 0) { + params = url.slice(index); + url = url.slice(0, index); + } else { + params = ''; + } + return { + url: url, + params: params, + hash: hash + }; + }; + + pathFromUrl = function(url) { + var path; + url = splitUrl(url).url; + if (url.indexOf('file://') === 0) { + path = url.replace(/^file:\/\/(localhost)?/, ''); + } else { + path = url.replace(/^([^:]+:)?\/\/([^:\/]+)(:\d*)?\//, '/'); + } + return decodeURIComponent(path); + }; + + pickBestMatch = function(path, objects, pathFunc) { + var bestMatch, object, score, _i, _len; + bestMatch = { + score: 0 + }; + for (_i = 0, _len = objects.length; _i < _len; _i++) { + object = objects[_i]; + score = numberOfMatchingSegments(path, pathFunc(object)); + if (score > bestMatch.score) { + bestMatch = { + object: object, + score: score + }; + } + } + if (bestMatch.score > 0) { + return bestMatch; + } else { + return null; + } + }; + + numberOfMatchingSegments = function(path1, path2) { + var comps1, comps2, eqCount, len; + path1 = path1.replace(/^\/+/, '').toLowerCase(); + path2 = path2.replace(/^\/+/, '').toLowerCase(); + if (path1 === path2) { + return 10000; + } + comps1 = path1.split('/').reverse(); + comps2 = path2.split('/').reverse(); + len = Math.min(comps1.length, comps2.length); + eqCount = 0; + while (eqCount < len && comps1[eqCount] === comps2[eqCount]) { + ++eqCount; + } + return eqCount; + }; + + pathsMatch = function(path1, path2) { + return numberOfMatchingSegments(path1, path2) > 0; + }; + + IMAGE_STYLES = [ + { + selector: 'background', + styleNames: ['backgroundImage'] + }, { + selector: 'border', + styleNames: ['borderImage', 'webkitBorderImage', 'MozBorderImage'] + } + ]; + + exports.Reloader = Reloader = (function() { + function Reloader(window, console, Timer) { + this.window = window; + this.console = console; + this.Timer = Timer; + this.document = this.window.document; + this.importCacheWaitPeriod = 200; + this.plugins = []; + } + + Reloader.prototype.addPlugin = function(plugin) { + return this.plugins.push(plugin); + }; + + Reloader.prototype.analyze = function(callback) { + return results; + }; + + Reloader.prototype.reload = function(path, options) { + var plugin, _base, _i, _len, _ref; + this.options = options; + if ((_base = this.options).stylesheetReloadTimeout == null) { + _base.stylesheetReloadTimeout = 15000; + } + _ref = this.plugins; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + plugin = _ref[_i]; + if (plugin.reload && plugin.reload(path, options)) { + return; + } + } + if (options.liveCSS) { + if (path.match(/\.css$/i)) { + if (this.reloadStylesheet(path)) { + return; + } + } + } + if (options.liveImg) { + if (path.match(/\.(jpe?g|png|gif)$/i)) { + this.reloadImages(path); + return; + } + } + return this.reloadPage(); + }; + + Reloader.prototype.reloadPage = function() { + return this.window.document.location.reload(); + }; + + Reloader.prototype.reloadImages = function(path) { + var expando, img, selector, styleNames, styleSheet, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1, _ref2, _ref3, _results; + expando = this.generateUniqueString(); + _ref = this.document.images; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + img = _ref[_i]; + if (pathsMatch(path, pathFromUrl(img.src))) { + img.src = this.generateCacheBustUrl(img.src, expando); + } + } + if (this.document.querySelectorAll) { + for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { + _ref1 = IMAGE_STYLES[_j], selector = _ref1.selector, styleNames = _ref1.styleNames; + _ref2 = this.document.querySelectorAll("[style*=" + selector + "]"); + for (_k = 0, _len2 = _ref2.length; _k < _len2; _k++) { + img = _ref2[_k]; + this.reloadStyleImages(img.style, styleNames, path, expando); + } + } + } + if (this.document.styleSheets) { + _ref3 = this.document.styleSheets; + _results = []; + for (_l = 0, _len3 = _ref3.length; _l < _len3; _l++) { + styleSheet = _ref3[_l]; + _results.push(this.reloadStylesheetImages(styleSheet, path, expando)); + } + return _results; + } + }; + + Reloader.prototype.reloadStylesheetImages = function(styleSheet, path, expando) { + var e, rule, rules, styleNames, _i, _j, _len, _len1; + try { + rules = styleSheet != null ? styleSheet.cssRules : void 0; + } catch (_error) { + e = _error; + } + if (!rules) { + return; + } + for (_i = 0, _len = rules.length; _i < _len; _i++) { + rule = rules[_i]; + switch (rule.type) { + case CSSRule.IMPORT_RULE: + this.reloadStylesheetImages(rule.styleSheet, path, expando); + break; + case CSSRule.STYLE_RULE: + for (_j = 0, _len1 = IMAGE_STYLES.length; _j < _len1; _j++) { + styleNames = IMAGE_STYLES[_j].styleNames; + this.reloadStyleImages(rule.style, styleNames, path, expando); + } + break; + case CSSRule.MEDIA_RULE: + this.reloadStylesheetImages(rule, path, expando); + } + } + }; + + Reloader.prototype.reloadStyleImages = function(style, styleNames, path, expando) { + var newValue, styleName, value, _i, _len; + for (_i = 0, _len = styleNames.length; _i < _len; _i++) { + styleName = styleNames[_i]; + value = style[styleName]; + if (typeof value === 'string') { + newValue = value.replace(/\burl\s*\(([^)]*)\)/, (function(_this) { + return function(match, src) { + if (pathsMatch(path, pathFromUrl(src))) { + return "url(" + (_this.generateCacheBustUrl(src, expando)) + ")"; + } else { + return match; + } + }; + })(this)); + if (newValue !== value) { + style[styleName] = newValue; + } + } + } + }; + + Reloader.prototype.reloadStylesheet = function(path) { + var imported, link, links, match, style, _i, _j, _k, _l, _len, _len1, _len2, _len3, _ref, _ref1; + links = (function() { + var _i, _len, _ref, _results; + _ref = this.document.getElementsByTagName('link'); + _results = []; + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + link = _ref[_i]; + if (link.rel.match(/^stylesheet$/i) && !link.__LiveReload_pendingRemoval) { + _results.push(link); + } + } + return _results; + }).call(this); + imported = []; + _ref = this.document.getElementsByTagName('style'); + for (_i = 0, _len = _ref.length; _i < _len; _i++) { + style = _ref[_i]; + if (style.sheet) { + this.collectImportedStylesheets(style, style.sheet, imported); + } + } + for (_j = 0, _len1 = links.length; _j < _len1; _j++) { + link = links[_j]; + this.collectImportedStylesheets(link, link.sheet, imported); + } + if (this.window.StyleFix && this.document.querySelectorAll) { + _ref1 = this.document.querySelectorAll('style[data-href]'); + for (_k = 0, _len2 = _ref1.length; _k < _len2; _k++) { + style = _ref1[_k]; + links.push(style); + } + } + this.console.log("LiveReload found " + links.length + " LINKed stylesheets, " + imported.length + " @imported stylesheets"); + match = pickBestMatch(path, links.concat(imported), (function(_this) { + return function(l) { + return pathFromUrl(_this.linkHref(l)); + }; + })(this)); + if (match) { + if (match.object.rule) { + this.console.log("LiveReload is reloading imported stylesheet: " + match.object.href); + this.reattachImportedRule(match.object); + } else { + this.console.log("LiveReload is reloading stylesheet: " + (this.linkHref(match.object))); + this.reattachStylesheetLink(match.object); + } + } else { + this.console.log("LiveReload will reload all stylesheets because path '" + path + "' did not match any specific one"); + for (_l = 0, _len3 = links.length; _l < _len3; _l++) { + link = links[_l]; + this.reattachStylesheetLink(link); + } + } + return true; + }; + + Reloader.prototype.collectImportedStylesheets = function(link, styleSheet, result) { + var e, index, rule, rules, _i, _len; + try { + rules = styleSheet != null ? styleSheet.cssRules : void 0; + } catch (_error) { + e = _error; + } + if (rules && rules.length) { + for (index = _i = 0, _len = rules.length; _i < _len; index = ++_i) { + rule = rules[index]; + switch (rule.type) { + case CSSRule.CHARSET_RULE: + continue; + case CSSRule.IMPORT_RULE: + result.push({ + link: link, + rule: rule, + index: index, + href: rule.href + }); + this.collectImportedStylesheets(link, rule.styleSheet, result); + break; + default: + break; + } + } + } + }; + + Reloader.prototype.waitUntilCssLoads = function(clone, func) { + var callbackExecuted, executeCallback, poll; + callbackExecuted = false; + executeCallback = (function(_this) { + return function() { + if (callbackExecuted) { + return; + } + callbackExecuted = true; + return func(); + }; + })(this); + clone.onload = (function(_this) { + return function() { + _this.console.log("LiveReload: the new stylesheet has finished loading"); + _this.knownToSupportCssOnLoad = true; + return executeCallback(); + }; + })(this); + if (!this.knownToSupportCssOnLoad) { + (poll = (function(_this) { + return function() { + if (clone.sheet) { + _this.console.log("LiveReload is polling until the new CSS finishes loading..."); + return executeCallback(); + } else { + return _this.Timer.start(50, poll); + } + }; + })(this))(); + } + return this.Timer.start(this.options.stylesheetReloadTimeout, executeCallback); + }; + + Reloader.prototype.linkHref = function(link) { + return link.href || link.getAttribute('data-href'); + }; + + Reloader.prototype.reattachStylesheetLink = function(link) { + var clone, parent; + if (link.__LiveReload_pendingRemoval) { + return; + } + link.__LiveReload_pendingRemoval = true; + if (link.tagName === 'STYLE') { + clone = this.document.createElement('link'); + clone.rel = 'stylesheet'; + clone.media = link.media; + clone.disabled = link.disabled; + } else { + clone = link.cloneNode(false); + } + clone.href = this.generateCacheBustUrl(this.linkHref(link)); + parent = link.parentNode; + if (parent.lastChild === link) { + parent.appendChild(clone); + } else { + parent.insertBefore(clone, link.nextSibling); + } + return this.waitUntilCssLoads(clone, (function(_this) { + return function() { + var additionalWaitingTime; + if (/AppleWebKit/.test(navigator.userAgent)) { + additionalWaitingTime = 5; + } else { + additionalWaitingTime = 200; + } + return _this.Timer.start(additionalWaitingTime, function() { + var _ref; + if (!link.parentNode) { + return; + } + link.parentNode.removeChild(link); + clone.onreadystatechange = null; + return (_ref = _this.window.StyleFix) != null ? _ref.link(clone) : void 0; + }); + }; + })(this)); + }; + + Reloader.prototype.reattachImportedRule = function(_arg) { + var href, index, link, media, newRule, parent, rule, tempLink; + rule = _arg.rule, index = _arg.index, link = _arg.link; + parent = rule.parentStyleSheet; + href = this.generateCacheBustUrl(rule.href); + media = rule.media.length ? [].join.call(rule.media, ', ') : ''; + newRule = "@import url(\"" + href + "\") " + media + ";"; + rule.__LiveReload_newHref = href; + tempLink = this.document.createElement("link"); + tempLink.rel = 'stylesheet'; + tempLink.href = href; + tempLink.__LiveReload_pendingRemoval = true; + if (link.parentNode) { + link.parentNode.insertBefore(tempLink, link); + } + return this.Timer.start(this.importCacheWaitPeriod, (function(_this) { + return function() { + if (tempLink.parentNode) { + tempLink.parentNode.removeChild(tempLink); + } + if (rule.__LiveReload_newHref !== href) { + return; + } + parent.insertRule(newRule, index); + parent.deleteRule(index + 1); + rule = parent.cssRules[index]; + rule.__LiveReload_newHref = href; + return _this.Timer.start(_this.importCacheWaitPeriod, function() { + if (rule.__LiveReload_newHref !== href) { + return; + } + parent.insertRule(newRule, index); + return parent.deleteRule(index + 1); + }); + }; + })(this)); + }; + + Reloader.prototype.generateUniqueString = function() { + return 'livereload=' + Date.now(); + }; + + Reloader.prototype.generateCacheBustUrl = function(url, expando) { + var hash, oldParams, originalUrl, params, _ref; + if (expando == null) { + expando = this.generateUniqueString(); + } + _ref = splitUrl(url), url = _ref.url, hash = _ref.hash, oldParams = _ref.params; + if (this.options.overrideURL) { + if (url.indexOf(this.options.serverURL) < 0) { + originalUrl = url; + url = this.options.serverURL + this.options.overrideURL + "?url=" + encodeURIComponent(url); + this.console.log("LiveReload is overriding source URL " + originalUrl + " with " + url); + } + } + params = oldParams.replace(/(\?|&)livereload=(\d+)/, function(match, sep) { + return "" + sep + expando; + }); + if (params === oldParams) { + if (oldParams.length === 0) { + params = "?" + expando; + } else { + params = "" + oldParams + "&" + expando; + } + } + return url + params + hash; + }; + + return Reloader; + + })(); + +}).call(this); + +},{}],8:[function(require,module,exports){ +(function() { + var CustomEvents, LiveReload, k; + + CustomEvents = require('./customevents'); + + LiveReload = window.LiveReload = new (require('./livereload').LiveReload)(window); + + for (k in window) { + if (k.match(/^LiveReloadPlugin/)) { + LiveReload.addPlugin(window[k]); + } + } + + LiveReload.addPlugin(require('./less')); + + LiveReload.on('shutdown', function() { + return delete window.LiveReload; + }); + + LiveReload.on('connect', function() { + return CustomEvents.fire(document, 'LiveReloadConnect'); + }); + + LiveReload.on('disconnect', function() { + return CustomEvents.fire(document, 'LiveReloadDisconnect'); + }); + + CustomEvents.bind(document, 'LiveReloadShutDown', function() { + return LiveReload.shutDown(); + }); + +}).call(this); + +},{"./customevents":2,"./less":3,"./livereload":4}],9:[function(require,module,exports){ +(function() { + var Timer; + + exports.Timer = Timer = (function() { + function Timer(func) { + this.func = func; + this.running = false; + this.id = null; + this._handler = (function(_this) { + return function() { + _this.running = false; + _this.id = null; + return _this.func(); + }; + })(this); + } + + Timer.prototype.start = function(timeout) { + if (this.running) { + clearTimeout(this.id); + } + this.id = setTimeout(this._handler, timeout); + return this.running = true; + }; + + Timer.prototype.stop = function() { + if (this.running) { + clearTimeout(this.id); + this.running = false; + return this.id = null; + } + }; + + return Timer; + + })(); + + Timer.start = function(timeout, func) { + return setTimeout(func, timeout); + }; + +}).call(this); + +},{}]},{},[8]); diff --git a/lib/jekyll/commands/serve/servlet.rb b/lib/jekyll/commands/serve/servlet.rb index 70b6d66d..640720ee 100644 --- a/lib/jekyll/commands/serve/servlet.rb +++ b/lib/jekyll/commands/serve/servlet.rb @@ -5,6 +5,128 @@ require "webrick" module Jekyll module Commands class Serve + # This class is used to determine if the Servlet should modify a served file + # to insert the LiveReload script tags + class SkipAnalyzer + BAD_USER_AGENTS = [%r!MSIE!].freeze + + def self.skip_processing?(request, response, options) + new(request, response, options).skip_processing? + end + + def initialize(request, response, options) + @options = options + @request = request + @response = response + end + + def skip_processing? + !html? || chunked? || inline? || bad_browser? + end + + def chunked? + @response["Transfer-Encoding"] == "chunked" + end + + def inline? + @response["Content-Disposition"] =~ %r!^inline! + end + + def bad_browser? + BAD_USER_AGENTS.any? { |pattern| @request["User-Agent"] =~ pattern } + end + + def html? + @response["Content-Type"] =~ %r!text/html! + end + end + + # This class inserts the LiveReload script tags into HTML as it is served + class BodyProcessor + HEAD_TAG_REGEX = %r!|! + + attr_reader :content_length, :new_body, :livereload_added + + def initialize(body, options) + @body = body + @options = options + @processed = false + end + + def processed? + @processed + end + + # rubocop:disable Metrics/MethodLength + def process! + @new_body = [] + # @body will usually be a File object but Strings occur in rare cases + if @body.respond_to?(:each) + begin + @body.each { |line| @new_body << line.to_s } + ensure + @body.close + end + else + @new_body = @body.lines + end + + @content_length = 0 + @livereload_added = false + + @new_body.each do |line| + if !@livereload_added && line[" + document.write( + ' + TEMPLATE + ERB.new(Jekyll::Utils.strip_heredoc(template)) + end + + def livereload_args + # XHTML standard requires ampersands to be encoded as entities when in + # attributes. See http://stackoverflow.com/a/2190292 + src = "" + if @options["livereload_min_delay"] + src += "&mindelay=#{@options["livereload_min_delay"]}" + end + if @options["livereload_max_delay"] + src += "&maxdelay=#{@options["livereload_max_delay"]}" + end + if @options["livereload_port"] + src += "&port=#{@options["livereload_port"]}" + end + src + end + end + class Servlet < WEBrick::HTTPServlet::FileHandler DEFAULTS = { "Cache-Control" => "private, max-age=0, proxy-revalidate, " \ @@ -34,6 +156,21 @@ module Jekyll # rubocop:disable Naming/MethodName def do_GET(req, res) rtn = super + + if @jekyll_opts["livereload"] + return rtn if SkipAnalyzer.skip_processing?(req, res, @jekyll_opts) + + processor = BodyProcessor.new(res.body, @jekyll_opts) + processor.process! + res.body = processor.new_body + res.content_length = processor.content_length.to_s + + if processor.livereload_added + # Add a header to indicate that the page content has been modified + res["X-Rack-LiveReload"] = "1" + end + end + validate_and_ensure_charset(req, res) res.header.merge!(@headers) rtn diff --git a/lib/jekyll/commands/serve/websockets.rb b/lib/jekyll/commands/serve/websockets.rb new file mode 100644 index 00000000..1e5f948e --- /dev/null +++ b/lib/jekyll/commands/serve/websockets.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "http/parser" + +module Jekyll + module Commands + class Serve + # The LiveReload protocol requires the server to serve livereload.js over HTTP + # despite the fact that the protocol itself uses WebSockets. This custom connection + # class addresses the dual protocols that the server needs to understand. + class HttpAwareConnection < EventMachine::WebSocket::Connection + attr_reader :reload_body, :reload_size + + def initialize(_opts) + # If EventMachine SSL support on Windows ever gets better, the code below will + # set up the reactor to handle SSL + # + # @ssl_enabled = opts["ssl_cert"] && opts["ssl_key"] + # if @ssl_enabled + # em_opts[:tls_options] = { + # :private_key_file => Jekyll.sanitized_path(opts["source"], opts["ssl_key"]), + # :cert_chain_file => Jekyll.sanitized_path(opts["source"], opts["ssl_cert"]) + # } + # em_opts[:secure] = true + # end + + # This is too noisy even for --verbose, but uncomment if you need it for + # a specific WebSockets issue. Adding ?LR-verbose=true onto the URL will + # enable logging on the client side. + # em_opts[:debug] = true + + em_opts = {} + super(em_opts) + + reload_file = File.join(Serve.singleton_class::LIVERELOAD_DIR, "livereload.js") + + @reload_body = File.read(reload_file) + @reload_size = @reload_body.bytesize + end + + # rubocop:disable Metrics/MethodLength + def dispatch(data) + parser = Http::Parser.new + parser << data + + # WebSockets requests will have a Connection: Upgrade header + if parser.http_method != "GET" || parser.upgrade? + super + elsif parser.request_url =~ %r!^\/livereload.js! + headers = [ + "HTTP/1.1 200 OK", + "Content-Type: application/javascript", + "Content-Length: #{reload_size}", + "", + "", + ].join("\r\n") + send_data(headers) + + # stream_file_data would free us from keeping livereload.js in memory + # but JRuby blocks on that call and never returns + send_data(reload_body) + close_connection_after_writing + else + body = "This port only serves livereload.js over HTTP.\n" + headers = [ + "HTTP/1.1 400 Bad Request", + "Content-Type: text/plain", + "Content-Length: #{body.bytesize}", + "", + "", + ].join("\r\n") + send_data(headers) + send_data(body) + close_connection_after_writing + end + end + end + end + end +end diff --git a/lib/jekyll/utils.rb b/lib/jekyll/utils.rb index 55cf9be1..1d3cb9c4 100644 --- a/lib/jekyll/utils.rb +++ b/lib/jekyll/utils.rb @@ -8,6 +8,7 @@ module Jekyll autoload :Internet, "jekyll/utils/internet" autoload :Platforms, "jekyll/utils/platforms" autoload :Rouge, "jekyll/utils/rouge" + autoload :ThreadEvent, "jekyll/utils/thread_event" autoload :WinTZ, "jekyll/utils/win_tz" # Constants for use in #slugify diff --git a/lib/jekyll/utils/thread_event.rb b/lib/jekyll/utils/thread_event.rb new file mode 100644 index 00000000..5afb50d9 --- /dev/null +++ b/lib/jekyll/utils/thread_event.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "thread" + +module Jekyll + module Utils + # Based on the pattern and code from + # https://emptysqua.re/blog/an-event-synchronization-primitive-for-ruby/ + class ThreadEvent + attr_reader :flag + + def initialize + @lock = Mutex.new + @cond = ConditionVariable.new + @flag = false + end + + def set + @lock.synchronize do + yield if block_given? + @flag = true + @cond.broadcast + end + end + + def wait + @lock.synchronize do + unless @flag + @cond.wait(@lock) + end + end + end + end + end +end diff --git a/test/test_commands_serve.rb b/test/test_commands_serve.rb index 135e83e2..fd1751d8 100644 --- a/test/test_commands_serve.rb +++ b/test/test_commands_serve.rb @@ -3,7 +3,10 @@ require "webrick" require "mercenary" require "helper" +require "httpclient" require "openssl" +require "thread" +require "tmpdir" class TestCommandsServe < JekyllUnitTest def custom_opts(what) @@ -12,6 +15,128 @@ class TestCommandsServe < JekyllUnitTest ) end + def start_server(opts) + @thread = Thread.new do + merc = nil + cmd = Jekyll::Commands::Serve + Mercenary.program(:jekyll) do |p| + merc = cmd.init_with_program(p) + end + merc.execute(:serve, opts) + end + @thread.abort_on_exception = true + + Jekyll::Commands::Serve.mutex.synchronize do + unless Jekyll::Commands::Serve.running? + Jekyll::Commands::Serve.run_cond.wait(Jekyll::Commands::Serve.mutex) + end + end + end + + def serve(opts) + allow(Jekyll).to receive(:configuration).and_return(opts) + allow(Jekyll::Commands::Build).to receive(:process) + + start_server(opts) + + opts + end + + context "using LiveReload" do + setup do + @temp_dir = Dir.mktmpdir("jekyll_livereload_test") + @destination = File.join(@temp_dir, "_site") + Dir.mkdir(@destination) || flunk("Could not make directory #{@destination}") + @client = HTTPClient.new + @client.connect_timeout = 5 + @standard_options = { + "port" => 4000, + "host" => "localhost", + "baseurl" => "", + "detach" => false, + "livereload" => true, + "source" => @temp_dir, + "destination" => @destination, + } + + site = instance_double(Jekyll::Site) + simple_page = <<-HTML.gsub(%r!^\s*!, "") + + + + + Hello World + + +

Hello! I am a simple web page.

+ + + HTML + + File.open(File.join(@destination, "hello.html"), "w") do |f| + f.write(simple_page) + end + allow(Jekyll::Site).to receive(:new).and_return(site) + end + + teardown do + capture_io do + Jekyll::Commands::Serve.shutdown + end + + Jekyll::Commands::Serve.mutex.synchronize do + if Jekyll::Commands::Serve.running? + Jekyll::Commands::Serve.run_cond.wait(Jekyll::Commands::Serve.mutex) + end + end + + FileUtils.remove_entry_secure(@temp_dir, true) + end + + should "serve livereload.js over HTTP on the default LiveReload port" do + skip_if_windows "EventMachine support on Windows is limited" + opts = serve(@standard_options) + content = @client.get_content( + "http://#{opts["host"]}:#{opts["livereload_port"]}/livereload.js" + ) + assert_match(%r!LiveReload.on!, content) + end + + should "serve nothing else over HTTP on the default LiveReload port" do + skip_if_windows "EventMachine support on Windows is limited" + opts = serve(@standard_options) + res = @client.get("http://#{opts["host"]}:#{opts["livereload_port"]}/") + assert_equal(400, res.status_code) + assert_match(%r!only serves livereload.js!, res.content) + end + + should "insert the LiveReload script tags" do + skip_if_windows "EventMachine support on Windows is limited" + opts = serve(@standard_options) + content = @client.get_content( + "http://#{opts["host"]}:#{opts["port"]}/#{opts["baseurl"]}/hello.html" + ) + assert_match( + %r!livereload.js\?snipver=1&port=#{opts["livereload_port"]}!, + content + ) + assert_match(%r!I am a simple web page!, content) + end + + should "apply the max and min delay options" do + skip_if_windows "EventMachine support on Windows is limited" + opts = serve(@standard_options.merge( + "livereload_max_delay" => "1066", + "livereload_min_delay" => "3" + )) + content = @client.get_content( + "http://#{opts["host"]}:#{opts["port"]}/#{opts["baseurl"]}/hello.html" + ) + assert_match(%r!&mindelay=3!, content) + assert_match(%r!&maxdelay=1066!, content) + end + end + context "with a program" do setup do @merc = nil