#!/usr/bin/env ruby VERSION = 'harness.rb 1.0' DOCUMENTATION = <args" case $line[1] in list) _arguments "1: :(build run test clean)";; build|clean|run|test) _harness_rb_complete "$command" "$line[1]";; esac } _harness_rb END_OF_ZSH_COMPLETIONS require 'etc' require 'fileutils' require 'getoptlong' require 'open-uri' require 'open3' require 'set' # The big state machine that actually controls the build. class Harness # A task is a thing that has some combination of {build, clean, run, # test} actions. It has dependencies and produces outputs when its # build action is called. All other tasks inherit this. class Task # the build, clean, run, test methods of any Task must return a # Result. Output is a string that is only printed if it is # nonempty (regardless of success). Result = Struct.new(:success, :output) include Comparable attr_reader :dependencies, :description, :outputs, :pressures def initialize(description, dependencies, outputs) @description = description @dependencies = dependencies @outputs = outputs @pressures = {clean: 1} end def canonical_name @outputs.first end def last_build mtimes = [] @outputs.each do |output| begin mtimes << File.mtime(output) rescue Errno::ENOENT => e return nil end end mtimes.max end def <=>(other) # We use <=> (and <=, <, >, >= because of Comparable) to order # Tasks in time. This may or may not actually be that good of # an idea, and is arguably confusing, but it seems fine. my_last_build = self.last_build other_last_build = other.last_build return -1 if my_last_build == nil || other_last_build == nil my_last_build <=> other_last_build end def clean @outputs.each do |output| FileUtils.rm_rf(output) end end end # An action is a Task + some metadata to tell us method we want to # call. Action = Struct.new(:action, :task, :dependencies) do def to_status case action when :build task.description when :clean "Cleaning #{task.canonical_name}..." when :test "Testing #{task.canonical_name}..." end end end # BuildStatus is returned from every iteration of build_step(), it # is has the state used to help with IO. BuildStatus = Struct.new( :done, :tasks_started, :tasks_active, :tasks_completed, :tasks_failed, :unqueued, ) # Worker process, handles manually doing IPC because the ractor # built-ins are not good enough yet (as of Ruby 3.4.0). For example # open-uri does not like to be run on the non-main ractor. Worker = Struct.new(:pid, :in_pipe, :out_pipe, :active) def initialize @outputs = Hash.new @action_set = [] @extra_args = [] @ready_queue = [] @workers = [] @workers_ready = [] @curr_pressure = 0 @seen_failed = false end def set_tasks(tasks) raise 'No tasks defined.' if tasks == nil || tasks.empty? raise 'Tasks already set.' if @outputs.length > 0 tasks.each do |task| task.outputs.each do |output| raise "Output #{output} claimed by multiple rules." if @outputs.include?(output) @outputs[output] = task end end end def get_build_set(curr, path) raise "Cycle detected via #{curr}." if path.include?(curr) raise "Missing task that outputs #{curr}." unless @outputs.key?(curr) build_set = Set.new build_set.add(curr) if @outputs[curr].last_build == nil path.push(curr) @outputs[curr].dependencies.each do |dep| dep_build_set = get_build_set(dep, path) if !dep_build_set.empty? build_set |= dep_build_set build_set.add(curr) elsif @outputs[curr] < @outputs[dep] build_set.add(curr) end end path.pop build_set end private :get_build_set def multimatch(target) if target.end_with?('...') prefix = target[0...-3] @outputs.keys.select{ |output| output.start_with?(prefix) } elsif @outputs.key?(target) [target] else [] end end private :multimatch def set_want(action, target, args) raise 'Wants already set.' if @action_set.length > 0 build_set = Set.new case action when :build matches = multimatch(target).keep_if { |match| @outputs[match].pressures.key?(:build) } raise "Given TARGET #{target} does not match any valid TARGETs with build." if matches.length == 0 matches.each do |match| build_set |= get_build_set(match, []) end when :clean matches = multimatch(target).keep_if { |match| @outputs[match].pressures.key?(:clean) } raise "Given TARGET #{target} does not match any valid TARGETs with clean." if matches.length == 0 matches.each do |match| dependencies = matches.select{ |other| match != other && other.start_with?(match) }.to_set dependencies.subtract(@outputs[match].outputs) @action_set.push(Action.new(:clean, @outputs[match], dependencies)) end when :test matches = multimatch(target).keep_if { |match| @outputs[match].pressures.key?(:test) } raise "Given TARGET #{target} does not match any valid TARGETs with test." if matches.length == 0 matches.each do |match| build_set |= get_build_set(match, []) test_action = Action.new(:test, @outputs[match], Set[match] & build_set) @action_set.push(test_action) end else raise "Invalid COMMAND #{command}." end build_set.each do |curr| action = Action.new(:build, @outputs[curr], @outputs[curr].dependencies.to_set & build_set) @action_set.push(action) end @action_set.uniq! { |action| [action.action, action.task] } # Sanity check that the actions that we are trying to do indeed # have a well defined pressure (which is the same as saying the # action makes sense to be run). @action_set.each do |action| raise "Task for #{action.task.outputs.first} does not a #{action.action} action." unless action.task.pressures.key?(action.action) end @extra_args = args end def worker_run(in_pipe, out_pipe) worker_dir = Dir.pwd loop do # Always ensure we start in the same directory. Dir.chdir(worker_dir) command = in_pipe.gets.chomp.to_sym break if command == :die # Using eval here is lazy and kinda silly but it works fine (and # makes it so I don't have to think too hard about encodings). # Anyways this should be replaced with ractors once they're good # enough. target = eval(in_pipe.gets.chomp) task = @outputs[target] begin case command when :build result = task.build when :clean task.clean result = Task::Result.new(true, '') when :test result = task.test(@extra_args) else raise "Worker unknown command #{command} (you should never see this)." end rescue StandardError => e result = Task::Result.new(false, e.inspect) end begin task.clean if command == :build && !result.success rescue StandardError => e result = Task::Result.new( false, "Failed originally with #{result.status}, which triggered a clean, which failed with #{e.inspect}.", ) end out_pipe.write("#{result.success}\n#{result.output.inspect}\n") end in_pipe.close out_pipe.close exit 0 end private :worker_run def build_start(worker_count) raise 'Build already started.' if @workers.length > 0 raise 'Number of workers must be positive.' if worker_count <= 0 # As a heuristic to decrease startup time on machines that have # lots of cores, we only create at most the number of workers as # there are desired actions. # # How much does this matter? On my a 32-thread Ryzen 7950X # machine, the startup time when doing an incremental C build goes # from ~70ms to ~17ms! For context it goes from feeling almost # instant to feeling instant. worker_count = [worker_count, @action_set.length].min worker_count.times do in_reader, in_writer = IO.pipe out_reader, out_writer = IO.pipe pid = fork do # We need to clean up extra pipes to ensure all processes # actually exit in case we crash instead of just being left # waiting on a pipe. @workers.each do |worker| worker.in_pipe.close worker.out_pipe.close end in_writer.close out_reader.close worker_run(in_reader, out_writer) raise 'Wroker should never leave woker_run (you shoule never see this).' end in_reader.close out_writer.close @workers << Worker.new(pid, in_writer, out_reader, nil) end end def build_step status = BuildStatus.new(false, [], [], [], [], 0) # First, we need to get any results back from the workers update # the dependencies of @action_set. @workers_ready.each do |pipe| worker = @workers.find { |worker| worker.out_pipe == pipe } # Using eval here is lazy and kinda silly but it works fine (and # makes it so I don't have to think too hard about encodings). # Anyways this should be replaced with ractors once they're good # enough. success = eval(worker.out_pipe.gets.chomp) output = eval(worker.out_pipe.gets.chomp) if success @action_set.each do |action| action.dependencies.subtract(worker.active.task.outputs) end status.tasks_completed << [worker.active.to_status, output] else @seen_failed = true status.tasks_failed << [worker.active.to_status, output] end @curr_pressure -= worker.active.task.pressures[worker.active.action] worker.active = nil end # Second we need to report the tasks that are still in progress. @workers.each do |worker| status.tasks_active << worker.active.to_status if worker.active != nil end # Third we need to move anything that can be built from # @action_set to @ready_queue. now_ready = @action_set.filter { |action| action.dependencies.empty? } @action_set = @action_set.difference(now_ready) @ready_queue += now_ready # Finally, we schedule as much of @ready_queue as we can (by just # taking the first few elements, to prevent starvation), while # maintaining the pressure constraint. while !@seen_failed && !@ready_queue.empty? && @curr_pressure < @workers.length worker = @workers.find { |worker| worker.active == nil } break if worker == nil # To handle the case wher action.task.pressure <= 1 action = @ready_queue.shift worker.active = action @curr_pressure += action.task.pressures[action.action] status.tasks_started << worker.active.to_status worker.in_pipe.write("#{action.action}\n#{action.task.outputs.first.inspect}\n") end status.done = status.tasks_started.empty? && status.tasks_active.empty? status.unqueued = @ready_queue.length + @action_set.length status end def build_wait(timeout) ready = IO.select(@workers.map(&:out_pipe), [], [], timeout) if ready == nil @workers_ready = [] else @workers_ready = ready[0] end end def build_clean @workers.each do |worker| worker.in_pipe.write("die\n") worker.in_pipe.close worker.out_pipe.close end end def target?(target) @outputs.key?(target) end def list_targets(type) @outputs.keys.keep_if{ |output| @outputs[output].pressures.key?(type) }.sort end def run?(target) @outputs[target].pressures.key?(:run) end def run(target) @outputs[target].run(@extra_args) end end # Collection of basic "built-in" useful tasks. module Tasks class Command < Harness::Task def initialize( description, command, dependencies, outputs, directory: nil, pressure: 1, ignore_success_output: false, touch: [] ) super( description, dependencies, outputs, ) @pressures.merge!({build: pressure}) @command = command @directory = directory @ignore_success_output = ignore_success_output @touch = touch end def build directory = Dir.pwd Dir.chdir(@directory) if @directory != nil output, status = Open3.capture2e(*@command) output = '' if @ignore_success_output && status.exitstatus == 0 Dir.chdir(directory) if @directory != nil # Some commands (looking at you configure and make scripts) can # be quite badly behaved w.r.t. timestamps, so to work around # this we have a touch list. @touch.each do |file| FileUtils.touch(file) end Result.new(status.exitstatus == 0, (output.empty? ? '' : "Command: #{@command.inspect}\n" + output)) end end class Copy < Harness::Task def initialize(path, source) super( "Copying #{path}...", [File.dirname(path), source], [path], ) @pressures.merge!({build: 1}) @path = path @source = source end def build FileUtils.cp(@source, @path) Result.new(true, '') end end class Download < Harness::Task def initialize(path, url, hash) super( "Downloading #{path}...", [File.dirname(path)], [path], ) @pressures.merge!({build: 1}) @path = path @url = url @hash = hash end def build IO.copy_stream(URI.open(@url), @path) actual_hash = Digest::SHA256.hexdigest(File.read(@path)) return Result.new(false, "Hash mismatch, expected #{@hash}, got #{actual_hash}") if @hash != actual_hash Result.new(true, '') end end class Directory < Harness::Task def initialize(path, needs_parent: true) parent = File.dirname(path) dependencies = [] dependencies << parent if needs_parent super( "Creating directory #{path}...", dependencies, [path], ) @pressures.merge!({build: 1}) @path = path end def last_build # Other actions may update the mtime for a directory, so we use # birth time instead. return File.birthtime(@path) if File.directory?(@path) nil end def build Dir.mkdir(@path) Result.new(true, '') end end class ETags < Command def initialize(sources) super( 'Generating TAGS...', ['etags'] + sources, sources, ['TAGS'], ) end end class RedactAndCopy < Harness::Task START_STRING = '@@' + 'BEGIN_REDACTION' + '@@' END_STRING = '@@' + 'END_REDACTION' + '@@' def initialize(path, source, notice) super( "Copying (and redacting) #{path}...", [File.dirname(path), source], [path], ) @pressures.merge!({build: 1}) @path = path @source = source @notice = notice end def build depth = 0 File.open(@path, 'w') do |f| File.read(@source).split("\n").each do |line| if line.include?(START_STRING) depth += 1 elsif line.include?(END_STRING) depth -= 1 return Result.new(false, 'More redaction endings than beginnings.') if depth < 0 if depth == 0 && @notice pre, post = line.split(END_STRING, 2) f.puts(pre + 'Some number of lines were redacted here.' + post) end elsif depth == 0 f.puts(line) end end end Result.new(true, '') end end class Source < Harness::Task def initialize(path) super( "Stub for #{path}...", [], [path], ) @pressures = Hash.new end def clean # Do nothing. This is technically not necessary because # @pressures guards against the superclass' version of this # method being called, however a bug bypassing that validation # might result in frustrating data loss. So to be safe we leave # this here. end end class Untar < Harness::Task def initialize(path, base, extra) super( "Unpacking #{path}...", [File.dirname(path), base], [path] + extra.map { |x| File.join(path, x) }, ) @pressures.merge!({build: 1}) @path = path @base = base end def last_build # Other actions may update the mtime for a directory, so we use # birth time instead. return File.birthtime(@path) if File.directory?(@path) nil end def build Dir.mkdir(@path) output, status = Open3.capture2e('tar', '-xf', @base, '--directory', @path, '--strip-components', '1') Result.new(status.exitstatus == 0, output) end def clean FileUtils.rm_rf(@path) end end end # Base class that the User class should inherit from. It has the # following variables which allow for modifying behaviours. # - @base_dir which is the dir relative to harness.rb that all # paths (outputs) are relative to. By default the value is '.'. # - @documentation which holds the user written documentation. # By default the value is nil. # - @ruby_version which contains a regex that is used to check # against the current ruby version. By default the value matches # ruby 3. # - @tasks which holds the actual array of tasks that need to be # set. By default it is empty. # - @version which holds a string for the user version. By default # it holds nil. # Of course, these should be set after calling super() in the # inheriting class. class BaseUser attr_reader :base_dir, :documentation, :ruby_version, :tasks, :version def initialize @base_dir = '.' @documentation = nil @ruby_version = /^3\.[0-9]+\.[0-9]+$/ @tasks = [] @version = nil end end # Configure some tasks that take some time in order to test the # parallelism of this build system and to stress test some IO. class SelfTestUser < BaseUser class MathTask < Harness::Task def initialize(id, dependencies) super( "Doing math (#{id})...", dependencies, [id], ) @pressures = {build: 1} end def build iterations = rand(10000..100000) (1..iterations).each do |x| 1000.times do x = Math.sqrt(x) x = x ** 2 end end Result.new(true, '') end def clean # Do nothing. This is technically not necessary because # @pressures guards against the superclass' version of this # method being called, however a bug bypassing that validation # might result in frustrating data loss. So to be safe we leave # this here. end end def initialize(n) raise 'Self test argument must be a positive integer.' if n <= 0 super() @tasks << MathTask.new('0', []) (1...n).each do |id| dependencies = [Math.log2(id).floor.to_s] @tasks << MathTask.new(id.to_s, dependencies) end end end # IO model for --quiet (ie. output literally nothing). class QuietIO def status(message) # Do nothing. end def warning(message) # Do nothing. end def usage(message = nil) exit 1 end end # IO model for --verbose (ie. output lines as we see them, making sure # not to hilariously repeat lines when the status changes). class BasicIO def initialize @status_seen = Set.new end def status(message) return if @status_seen.include?(message) && message.length > 0 @status_seen.add(message) puts message end def warning(message) puts message end def usage(message = nil) if message puts message puts end puts USAGE exit 1 end end # Default IO model (ie. print a status line that gets replaced, except # for warnings, and append a time stamp. class InteractiveIO def initialize(start_time) @start_time = start_time @curr_status_length = 0 end def clear_line print "\r" + (' ' * @curr_status_length) + "\r" end private :clear_line def status(message) clear_line return if message.length == 0 line = sprintf('[T+%.1fs] %s', Time.now - @start_time, message) print line @curr_status_length = line.length end def warning(message) puts puts message @curr_status_length = 0 end def usage(message = nil) clear_line if message puts message puts end puts USAGE exit 1 end end # Now that all of those classes are declared, we move on to the script # part of the harness, ie. the driver. opts = GetoptLong.new( ['--help', GetoptLong::NO_ARGUMENT], ['--quiet', GetoptLong::NO_ARGUMENT], ['--self-test', GetoptLong::REQUIRED_ARGUMENT], ['--user-help', GetoptLong::NO_ARGUMENT], ['--user-version', GetoptLong::NO_ARGUMENT], ['--verbose', GetoptLong::NO_ARGUMENT], ['--version', GetoptLong::NO_ARGUMENT], ['--workers', GetoptLong::REQUIRED_ARGUMENT], ['--zsh-completions', GetoptLong::NO_ARGUMENT], ) quiet = false verbose = false user_help = false user_version = false worker_count = [1, Etc.nprocessors - 1].max self_test = nil run_target = nil opts.each do |opt, arg| case opt when '--help' puts DOCUMENTATION exit 0 when '--quiet' quiet = true when '--self-test' self_test = arg.to_i when '--user-help' user_help = true when '--user-version' user_version = true when '--verbose' verbose = true when '--version' puts VERSION exit 0 when '--workers' worker_count = arg.to_i when '--zsh-completions' puts ZSH_COMPLETIONS exit 0 end end if quiet io = QuietIO.new elsif verbose io = BasicIO.new else io = InteractiveIO.new(checkpoint_times.first) end # Despite collecting our first time stamp much earlier (to get more # complete stats), it is only now that we hand over control to this IO # system. io.status('Thinking...') begin harness = Harness.new if self_test != nil user = SelfTestUser.new(self_test) else # By forcing everything into a class, we prevent arbitrary writes to # local variables in this scope. Of course, because we are just # passing this to eval, there will always be ways to hijack the # script, but we do still want to prevent the easy screw ups. eval(DATA.read) user = User.new(worker_count) end # Ensure we are being interpreted by an expected Ruby version. io.warning("Current Ruby version, #{RUBY_VERSION}, does not match expectations.") unless RUBY_VERSION =~ user.ruby_version if user_version io.usage('User version requested, but does not exist.') if user.version == nil io.status('') puts user.version exit 0 end if user_help io.usage('User help requested, but does not exist.') if user.documentation == nil io.status('') puts user.documentation exit 0 end # Ensure we are always building from user.base_dir. Dir.chdir(__dir__) Dir.chdir(user.base_dir) harness.set_tasks(user.tasks) io.usage('No COMMAND specified.') if ARGV.length < 1 io.usage('No TARGET specified.') if ARGV.length < 2 command = ARGV[0].to_sym target = ARGV[1] case command when :list target = target.to_sym io.usage("Invalid list target #{target}.") unless [:build, :clean, :run, :test].include?(target) io.status('') targets = harness.list_targets(target.to_sym) puts targets.join("\n") unless targets.empty? exit 0 when :run io.usage("Given TARGET #{target} does not match any valid TARGETs.") unless harness.target?(target) io.usage("Given TARGET #{target} cannot be run.") unless harness.run?(target) harness.set_want(:build, target, ARGV[2..]) run_target = target else harness.set_want(command, target, ARGV[2..]) end harness.build_start(worker_count) rescue RuntimeError => e io.usage(e.message) end checkpoint_times << Time.now io.status('Building...') tasks_completed = 0 tasks_failed = 0 tasks_stalled = 0 loop do status = harness.build_step tasks_completed += status.tasks_completed.length tasks_failed += status.tasks_failed.length # If any completed tasks have warnings we should output them. status.tasks_completed.each do |message, output| next if output.length == 0 io.status(message) io.warning(output) end # If we have any failures we definitely have to warn that a failure # occurred. This is the case even if status.done is set. status.tasks_failed.each do |message, output| io.status(message + ' FAILED') io.warning(output) end # Stop building if necessary. If this is set we definitely don't # have anything in status.tasks_active or status.tasks_failed, # otherwise this would cause a contradiction. if status.done tasks_stalled = status.unqueued break end # If we started in any new tasks make sure to acknowledge them all # (mainly useful in --verbose to make sure all tasks done appear at # least once). status.tasks_started.each do |message| io.status(message) end # Otherwise if we haven't started any new tasks, just pick a random # active task and use that as the current status (we could cycle # through them strategically, but meh). if status.tasks_started.length == 0 io.status(status.tasks_active.sample) end harness.build_wait(0.1) end # Run is a special case because we don't want to capture stdin/stdout, # so we can't parallelize it without chaos. This forces us to run it # on the main thread, and since we don't allow prefix matching with # run, we can just special case it an run it after the build. if run_target != nil if tasks_failed > 0 || tasks_stalled > 0 tasks_stalled += 1 else checkpoint_times << Time.now io.status("Running #{run_target}...") result = harness.run(target) if result.success tasks_completed += 1 else tasks_failed += 1 end io.warning(result.output) if result.output.length > 0 end end checkpoint_times << Time.now io.status('Finishing...') harness.build_clean checkpoint_times << Time.now diffs = (1...checkpoint_times.length).map do |i| sprintf('%.3fs', checkpoint_times[i] - checkpoint_times[i-1]) end time_message = sprintf( '%s=%.3fs', diffs.join('+'), checkpoint_times.last - checkpoint_times.first, ) if tasks_failed > 0 || tasks_stalled > 0 io.status('Build failed.') io.warning(sprintf( '(%d tasks completed, %d failed, %d stalled in %s)', tasks_completed, tasks_failed, tasks_stalled, time_message, )) exit 1 end io.status('Done!') io.warning(sprintf( '(%d tasks completed in %s)', tasks_completed, time_message, )) # When writing a build script using this harness, you should never # have to edit anything above the __END__ (unless you're adding a new # core feature or have found a bug). __END__ require 'socket' class User < BaseUser USER_VERSION = 'pta.gg-website-generator 2.5' USER_DOCUMENTATION = <'. The meta block is deemed ended on the first line consisting of exactly ''. Each line of the meta block specifies an option, followed by an '=' and then the value. The value may contain whitespace, but any whitespace around the '=' will be stripped. Valid options are: language - Page language in ISO format. Eg. 'en' for English. title - Self explanatory. subtitle - Goes underneath the title. description - The description to be used for social media embeds. image - The image to be used for social media embeds. (Should be a URL.) date-published - Self explanatory. date-updated - Self explanatory. has - Optional components to be loaded. redirect-to - Optional, contains a path for a redirect. If present no page content will be shown. Sample meta block:
title=Why HTML is Better Than Markdown date-published=5 February 2020 date-updated=2 March 2021 has=code latex
If you want to use this for your own website, most of the changes you need to make will be in the render, render_head, render_header, render_prebody, render_postbody, render_footer, and render_foot functions, and in the RenderSupport module. Additionally, note that when removing files, incremental rebuilds will not remove the files from the build directory. That being said cool URIs don't change (see https://www.w3.org/Provider/Style/URI), so you should avoid doing this if possible. Finally, note that while the licence of this harness forces copyleft on the website generator itself, including snippets of HTML that are included in the output verbatim, this copyleft should not extend to the actual generated content itself (if you're really paranoid about this, I would suggest you split out all the snippets into a separate file and read them dynamically). SPDX-FileCopyrightText: Copyright (c) 2022, 2025, Theodore Preduta. SPDX-License-Identifier: LiLiQ-R-1.1 END_OF_USER_DOCUMENTATION module RenderSupport Result = Harness::Task::Result # This may not be a good idea. MetaBlock = Struct.new( :location, :language, :title, :subtitle, :description, :image, :date_published, :date_updated, :has, :redirect_to, keyword_init: true, ) BASE_URL = 'https://www.pta.gg' def local_to_served_path(local_path) served_path = '/' + local_path if served_path.end_with?('/index.html') served_path = served_path[...-10] elsif served_path.end_with?('.html') served_path = served_path[...-5] + '/' end served_path.gsub(/\/+/, '/') end def parse_meta_block(contents) meta = MetaBlock.new( language: 'en', title: 'Unset Title', subtitle: nil, description: nil, image: nil, date_published: nil, date_updated: nil, has: Set.new, redirect_to: nil, ) line, contents = contents.split("\n", 2) return [meta, contents, Result.new(false, 'Meta block not detected.')] if line != '
' line, contents = contents.split("\n", 2) while line.chomp != '
' key, value = line.split('=', 2) key.strip! value.strip! case key when 'language' meta.language = value when 'title' meta.title = value when 'subtitle' meta.subtitle = value when 'description' meta.description = value when 'image' meta.image = value when 'date-published' meta.date_published = value when 'date-updated' meta.date_updated = value when 'has' meta.has = value.split(' ').map(&:to_sym).to_set when 'redirect-to' meta.redirect_to = value when '' # Do nothing. else return [meta, contents, Result.new(false, "Unknown meta block parameter #{key}.")] end line, contents = contents.split("\n", 2) end [meta, contents, Result.new(true, '')] end end class RenderHTML < Harness::Task include RenderSupport def initialize(out_dir, source) if source[-10..] == 'index.html' @extra_dir = nil @path = File.join(out_dir, source) else @extra_dir = File.join(out_dir, source[..-6]) @path = File.join(@extra_dir, 'index.html') end @source = source dependencies = [@source] outputs = [@path] if @extra_dir == nil dependencies << File.dirname(@path) else dependencies << File.dirname(@extra_dir) outputs << @extra_dir end @location = local_to_served_path(source) super( "Rendering #{@path}...", dependencies + ['harness.rb'], outputs ) @pressures.merge!({build: 1}) end def last_build begin File.mtime(@path) rescue Errno::ENOENT => e nil end end def build Dir.mkdir(@extra_dir) if @extra_dir != nil && !File.directory?(@extra_dir) output, result = render(File.read(@source)) return result unless result.success File.write(@path, output) Result.new(true, '') end def render(contents) meta, contents, result = parse_meta_block(contents) if meta.redirect_to != nil [< Redirect

Redirecting here...

EOF else [< #{render_head(meta)}
#{render_header(meta)}
#{render_prebody(meta)} #{contents} #{render_postbody(meta)}
#{render_footer(meta)}
#{render_foot(meta)} EOF end end def render_head(meta) output = < EOF output += < EOF output += < EOF output += < #{meta.title} EOF output += < EOF output += < EOF output end def render_header(meta) output = <#{meta.title} EOF output += <#{meta.subtitle} EOF output += < Skip Navigation Home About Projects EOF output end def render_prebody(meta) output = '' if meta.date_published != nil || meta.date_updated != nil output += '

' output += "Published #{meta.date_published}" if meta.date_published != nil output += "; " if meta.date_published != nil && meta.date_updated != nil output += "Updated #{meta.date_updated}" if meta.date_updated != nil output += '.

' end output end def render_postbody(meta) '' # Unused end def render_footer(meta) < Main Site Version Control Licences Enable Chaos

© 2021-2025, Theodore Preduta. All rights reserved.

EOF end def render_foot(meta) output = < EOF output += < EOF output += < EOF output end end class RenderRSS < Harness::Task include RenderSupport RSS_DATE_FORMAT = '%a, %-d %b %Y %H:%M:%S %z' # This is insane. def initialize(build_dir, target, sources, title, description, limit: 0) @path = File.join(build_dir, target, 'feed.xml') super( "Generating #{@path}...", sources + [File.dirname(@path), 'harness.rb'], [@path] ) @pressures.merge!({build: 1}) @sources = sources @rss_title = title @rss_description = description @served_path = local_to_served_path(File.join(target, 'feed.xml')) @html_served_path = local_to_served_path(File.join(target, 'index.html')) @limit = limit - 1 end def build output, result = render return result unless result.success File.write(@path, output) Result.new(true, '') end def render entries = @sources.map do |source| contents = File.read(source) meta, contents, result = parse_meta_block(contents) return ['', result] unless result.success return ['', Result.new(false, "RSS entry from #{source} is missing date-published in meta block.")] if meta.date_published == nil date = DateTime.parse(meta.date_published) served_path = local_to_served_path(source) [< #{meta.title} #{date.strftime(RSS_DATE_FORMAT)} #{BASE_URL}#{served_path} #{BASE_URL}#{served_path} EOF end entries.sort_by! { |x| x[1] } entries.reverse! entries.map! { |x| x[0] } [< #{@rss_title} #{@rss_description} #{BASE_URL}#{@html_served_path} #{DateTime.now.new_offset(0).strftime(RSS_DATE_FORMAT)} #{USER_VERSION} driven by #{VERSION} #{entries[..@limit].join("\n")} EOF end end class RenderSitemap < Harness::Task include RenderSupport def initialize(build_dir, sources) @path = File.join(build_dir, 'sitemap.xml') super( "Generating #{@path}...", sources + [File.dirname(@path), 'harness.rb'], [@path] ) @pressures.merge!({build: 1}) @sources = sources end def build output = < EOF @sources.map! { |x| [x, local_to_served_path(x)] } @sources.sort_by! { |x| x[1] } @sources.each do |source, url| output += < #{BASE_URL}#{url} EOF end output += < EOF File.write(@path, output) Result.new(true, '') end end # Worlds worst webserver (but it works). class LocalServer < Harness::Task SUFFIX_TO_CONTENT_TYPE = { '.css' => 'text/css; charset=utf-8', '.html' => 'text/html; charset=utf-8', '.ico' => 'image/x-icon', '.js' => 'text/javascript; charset=utf-8', '.rb' => 'text/plain; charset=utf-8', '.sh' => 'text/plain; charset=utf-8', '.txt' => 'text/plain; charset=utf-8', '.xml' => 'text/xml; charset=utf-8', } RESPONSE_CODES = { 200 => '200 OK', 404 => '404 Not Found', 500 => '500 Internal Server Error', } def initialize(directory) super( 'You should never see this...', [], ['local-server'], ) @pressures = {build: 1, run: 1} @directory = directory end def last_build # TODO remove this stupid hack that's needed because things that # have a run need to have a build. Time.now end def run(args) port = 8080 port = args[0].to_i if args.length > 0 return Result.new(false, "Invalid port #{port}.") if port <= 0 last_harness_time = Time.at(0) server = TCPServer.new(port) puts "Listening on port #{port} (press C-c or send SIGINT to stop)..." loop do client = server.accept start_time = Time.now method, path, version = client.gets.chomp.split(' ', 3) client.close if method != 'GET' || version != 'HTTP/1.1' puts "[#{start_time.strftime('%Y-%m-%d %H:%M:%S.%L')}] => GET #{path}" # We don't actually want to run harness.rb on every request # because then the speed of this web server is limited by the # start up time of Ruby... which isn't that great. # # In an ideal world, Ruby would have some platform agnostic # api for inotify(7) or kqueue(2), allowing us to watch for # changes in a separate thread, but that doesn't exist. So # instead we settle for the following heuristic: harness.rb # was run in the last 100ms, we are probably dealing with # requests for the same web page, so we just directly serve # what already exists. # # TODO this could probably spead up by creating a new Harness # instance in this process and running it instead of running # an external process, but this has the downside that it's # harder to dynamically update the website generation code. if (Time.now - last_harness_time) < 0.1 response_code, content_type, output = get(path) elsif system("./harness.rb build #{@directory}...") last_harness_time = Time.now response_code, content_type, output = get(path) else response_code = 500 content_type = SUFFIX_TO_CONTENT_TYPE['.html'] output = "

#{RESPONSE_CODES[response_code]}

" end client.write "HTTP/1.1 #{RESPONSE_CODES[response_code]}\r\nContent-Length: #{output.bytesize}\r\nContent-Type: #{content_type}\r\n\r\n" client.write output client.close puts "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')}] <= #{response_code} #{content_type}" rescue Interrupt puts 'Caught SIGINT, cleaning up and exiting...' break rescue => e puts "ERROR: Caught #{e}." end Result.new(true, '') end def get(path, in_404: false) real_path = File.join(@directory, path) if File.directory?(real_path) get(File.join(path, 'index.html'), in_404: in_404) elsif File.file?(real_path) content_type = 'application/octet-stream' longest_match = '' SUFFIX_TO_CONTENT_TYPE.each do |key, value| if real_path.end_with?(key) && key.length > longest_match.length longest_match = key content_type = value end end [in_404 ? 404 : 200, content_type, File.read(real_path)] elsif path == '/favicon.ico' get('/assets/images/favicon.ico', in_404: in_404) elsif in_404 [404, SUFFIX_TO_CONTENT_TYPE['.html'], "

#{RESPONSE_CODES[404]}

…and /404/ does not exist.

"] else get('/404/', in_404: true) end end end def initialize(worker_count) super() @version = USER_VERSION @documentation = USER_DOCUMENTATION build_dir = 'build' # Some number of lines were redacted here. @tasks = [Tasks::Directory.new(build_dir, needs_parent: false)] directories = [ # Some number of lines were redacted here. ] @tasks += directories.map { |x| Tasks::Directory.new(File.join(build_dir, x)) } articles = [ # Some number of lines were redacted here. ] projects = [ # Some number of lines were redacted here. ] all_indexable = articles + projects + [ # Some number of lines were redacted here. ] all_renderable = all_indexable + [ # Some number of lines were redacted here. ] @tasks += all_renderable.map { |x| Tasks::Source.new(x) } @tasks += all_renderable.map { |x| RenderHTML.new(build_dir, x) } @tasks += [ RenderSitemap.new(build_dir, all_indexable), RenderRSS.new(build_dir, 'articles', articles, 'pta.gg Articles', 'Blog posts written by Theodore Preduta.'), RenderRSS.new(build_dir, 'projects', projects, 'pta.gg Projects', 'Projects created by Theodore Preduta (mostly software-related).'), ] static_assets = [ # Some number of lines were redacted here. ] @tasks += static_assets.map { |x| Tasks::Source.new(x) } @tasks += static_assets.map { |x| Tasks::Copy.new(File.join(build_dir, x), x) } @tasks += [ Tasks::Source.new('harness.rb'), Tasks::RedactAndCopy.new(File.join(build_dir, 'harness.rb'), 'harness.rb', true), ] @tasks << LocalServer.new(build_dir) end end