# Build tasks for samhuri.net static site generator require "fileutils" require "open3" require "tmpdir" LIB_PATH = File.expand_path("lib", __dir__).freeze $LOAD_PATH.unshift(LIB_PATH) unless $LOAD_PATH.include?(LIB_PATH) require "pressa/drafts" require "pressa/coverage" require "pressa/publish" DRAFTS_DIR = "public/drafts".freeze PUBLISH_HOST = "mudge".freeze PRODUCTION_PUBLISH_DIR = "/var/www/samhuri.net/public".freeze BETA_PUBLISH_DIR = "/var/www/beta.samhuri.net/public".freeze GEMINI_PUBLISH_DIR = "/var/gemini/samhuri.net".freeze WATCHABLE_DIRECTORIES = %w[public posts lib].freeze LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze BUILD_TARGETS = %w[debug mudge beta release gemini].freeze # Generate the site in debug mode (localhost:8000) def debug build("http://localhost:8000", output_format: "html", target_path: "www") end # Generate the site for the mudge development server def mudge build("http://mudge:8000", output_format: "html", target_path: "www") end # Generate the site for beta/staging def beta build("https://beta.samhuri.net", output_format: "html", target_path: "www") end # Generate the site for production def release build("https://samhuri.net", output_format: "html", target_path: "www") end # Generate the Gemini capsule for production def gemini build("https://samhuri.net", output_format: "gemini", target_path: "gemini") end # Start local development server def serve require "webrick" server = WEBrick::HTTPServer.new(Port: 8000, DocumentRoot: "www") trap("INT") { server.shutdown } puts "Server running at http://localhost:8000" server.start end # Create a new draft in public/drafts/. # @parameter title_parts [Array] Optional title words; defaults to Untitled. def new_draft(*title_parts) drafts = Pressa::Drafts.new(dir: DRAFTS_DIR) title, filename = if title_parts.empty? ["Untitled", drafts.next_available] else given_title = title_parts.join(" ") slug = Pressa::Drafts.slugify(given_title) abort "Error: title cannot be converted to a filename." if slug.empty? filename = "#{slug}.md" path = drafts.path(filename) abort "Error: draft already exists at #{path}" if File.exist?(path) [given_title, filename] end FileUtils.mkdir_p(DRAFTS_DIR) path = drafts.path(filename) content = drafts.render_template(title) File.write(path, content) puts "Created new draft at #{path}" puts ">>> Contents below <<<" puts puts content end # Publish a draft by moving it to posts/YYYY/MM and updating dates. # @parameter input_path [String] Draft path or filename in public/drafts. def publish_draft(input_path = nil) drafts = Pressa::Drafts.new(dir: DRAFTS_DIR) if input_path.nil? || input_path.strip.empty? puts "Usage: bake publish_draft " puts puts "Available drafts:" available = Dir.glob("#{DRAFTS_DIR}/*.md").map { |path| File.basename(path) } if available.empty? puts " (no drafts found)" else available.each { |draft| puts " #{draft}" } end abort end draft_path_value, draft_file = begin drafts.resolve_input(input_path) rescue Pressa::Drafts::Error => e abort "Error: #{e.message}" end abort "Error: File not found: #{draft_path_value}" unless File.exist?(draft_path_value) now = Time.now content = File.read(draft_path_value) content.sub!(/^Date:.*$/, "Date: #{Pressa::Drafts.ordinal_date(now)}") content.sub!(/^Timestamp:.*$/, "Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}") target_dir = "posts/#{now.strftime("%Y/%m")}" FileUtils.mkdir_p(target_dir) target_path = "#{target_dir}/#{draft_file}" File.write(target_path, content) FileUtils.rm_f(draft_path_value) puts "Published draft: #{draft_path_value} -> #{target_path}" end # Watch content directories and rebuild on every change. # @parameter target [String] One of debug, mudge, beta, release, or gemini. def watch(target: "debug") unless command_available?("inotifywait") abort "inotifywait is required (install inotify-tools)." end loop do abort "Error: watch failed." unless system("inotifywait", "-e", "modify,create,delete,move", *watch_paths) puts "changed at #{Time.now}" sleep 2 run_build_target(target) end end # Publish to beta/staging server def publish_beta beta run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true) end # Publish Gemini capsule to production def publish_gemini gemini run_rsync(local_paths: ["gemini/"], publish_dir: GEMINI_PUBLISH_DIR, dry_run: false, delete: true) end # Publish to production server def publish release run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true) publish_gemini end # Clean generated files def clean FileUtils.rm_rf("www") FileUtils.rm_rf("gemini") puts "Cleaned www/ and gemini/ directories" end # Default task: run coverage and lint. def default coverage lint end # Run Minitest tests def test run_test_suite(test_file_list) end # Run Guard for continuous testing def guard exec "bundle exec guard" end # List all available drafts def drafts Dir.glob("#{DRAFTS_DIR}/*.md").sort.each do |draft| puts File.basename(draft) end end # Run StandardRB linter def lint run_standardrb end # Auto-fix StandardRB issues def lint_fix run_standardrb("--fix") end # Measure line coverage for files under lib/. # @parameter lowest [Integer] Number of lowest-covered files to print (default: 10, use 0 to hide). def coverage(lowest: 10) lowest_count = Integer(lowest) abort "Error: lowest must be >= 0." if lowest_count.negative? run_coverage(test_files: test_file_list, lowest_count:) end # Compare line coverage for files under lib/ against a baseline and fail on regression. # @parameter baseline [String] Baseline ref, or "merge-base" (default) to compare against merge-base with remote default branch. # @parameter lowest [Integer] Number of lowest-covered files to print for the current checkout (default: 10, use 0 to hide). def coverage_regression(baseline: "merge-base", lowest: 10) lowest_count = Integer(lowest) abort "Error: lowest must be >= 0." if lowest_count.negative? baseline_ref = Pressa::Coverage.resolve_baseline_ref(baseline) { coverage_merge_base_ref } baseline_commit = capture_command("git", "rev-parse", "--short", baseline_ref).strip puts "Running coverage for current checkout..." current_output = capture_coverage_output(test_files: test_file_list, lowest_count:, chdir: Dir.pwd) print current_output current_percent = Pressa::Coverage.parse_percent(current_output) puts "Running coverage for baseline #{baseline_ref} (#{baseline_commit})..." baseline_percent = with_temporary_worktree(ref: baseline_ref) do |worktree_path| baseline_tests = test_file_list(chdir: worktree_path) baseline_output = capture_coverage_output(test_files: baseline_tests, lowest_count: 0, chdir: worktree_path) Pressa::Coverage.parse_percent(baseline_output) end delta = current_percent - baseline_percent puts format("Baseline coverage (%s %s): %.2f%%", baseline_ref, baseline_commit, baseline_percent) puts format("Coverage delta: %+0.2f%%", delta) return unless delta.negative? abort format("Error: coverage regressed by %.2f%% against %s (%s).", -delta, baseline_ref, baseline_commit) end private def run_test_suite(test_files) run_command("ruby", "-Ilib", "-Itest", "-e", "ARGV.each { |file| require File.expand_path(file) }", *test_files) end def run_coverage(test_files:, lowest_count:) output = capture_coverage_output(test_files:, lowest_count:, chdir: Dir.pwd) print output end def test_file_list(chdir: Dir.pwd) test_files = Dir.chdir(chdir) { Dir.glob("test/**/*_test.rb").sort } abort "Error: no tests found in test/**/*_test.rb under #{chdir}" if test_files.empty? test_files end def capture_coverage_output(test_files:, lowest_count:, chdir:) capture_command("ruby", "-Ilib", "-Itest", "-e", Pressa::Coverage.script(lowest_count:), *test_files, chdir:) end def coverage_merge_base_ref remote = preferred_remote remote_head_ref = remote_default_branch_ref(remote) merge_base = capture_command("git", "merge-base", "HEAD", remote_head_ref).strip abort "Error: could not resolve merge-base with #{remote_head_ref}." if merge_base.empty? merge_base end def preferred_remote upstream = capture_command_optional("git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}").strip upstream_remote = upstream.split("/").first unless upstream.empty? return upstream_remote if upstream_remote && !upstream_remote.empty? remotes = capture_command("git", "remote").lines.map(&:strip).reject(&:empty?) abort "Error: no git remotes configured; pass baseline=." if remotes.empty? remotes.include?("origin") ? "origin" : remotes.first end def remote_default_branch_ref(remote) symbolic = capture_command_optional("git", "symbolic-ref", "--quiet", "refs/remotes/#{remote}/HEAD").strip if symbolic.empty? fallback = "#{remote}/main" capture_command("git", "rev-parse", "--verify", fallback) return fallback end symbolic.sub("refs/remotes/", "") end def with_temporary_worktree(ref:) temp_root = Dir.mktmpdir("coverage-baseline-") worktree_path = File.join(temp_root, "worktree") run_command("git", "worktree", "add", "--detach", worktree_path, ref) begin yield worktree_path ensure system("git", "worktree", "remove", "--force", worktree_path) FileUtils.rm_rf(temp_root) end end def capture_command(*command, chdir: Dir.pwd) stdout, stderr, status = Dir.chdir(chdir) { Open3.capture3(*command) } output = +"" output << stdout unless stdout.empty? output << stderr unless stderr.empty? abort "Error: command failed: #{command.join(" ")}\n#{output}" unless status.success? output end def capture_command_optional(*command, chdir: Dir.pwd) stdout, stderr, status = Dir.chdir(chdir) { Open3.capture3(*command) } return stdout if status.success? return "" if stderr.include?("no upstream configured") || stderr.include?("is not a symbolic ref") "" end # Build the site with specified URL and output format. # @parameter url [String] The site URL to use. # @parameter output_format [String] One of html or gemini. # @parameter target_path [String] Target directory for generated output. def build(url, output_format:, target_path:) require "pressa" puts "Building #{output_format} site for #{url}..." site = Pressa.create_site(source_path: ".", url_override: url, output_format:) generator = Pressa::SiteGenerator.new(site:) generator.generate(source_path: ".", target_path:) puts "Site built successfully in #{target_path}/" end def run_build_target(target) target_name = target.to_s unless BUILD_TARGETS.include?(target_name) abort "Error: invalid target '#{target_name}'. Use one of: #{BUILD_TARGETS.join(", ")}" end public_send(target_name) end def watch_paths WATCHABLE_DIRECTORIES.flat_map { |path| ["-r", path] } end def standardrb_command(*extra_args) ["bundle", "exec", "standardrb", *extra_args, *LINT_TARGETS] end def run_standardrb(*extra_args) run_command(*standardrb_command(*extra_args)) end def run_command(*command) abort "Error: command failed: #{command.join(" ")}" unless system(*command) end def run_rsync(local_paths:, publish_dir:, dry_run:, delete:) command = Pressa::Publish.rsync_command( local_paths:, host: PUBLISH_HOST, publish_dir:, dry_run:, delete: ) abort "Error: rsync failed." unless system(*command) end def command_available?(command) system("which", command, out: File::NULL, err: File::NULL) end