samhuri.net/bake.rb
Sami Samhuri 007b1058b6
Migrate from Swift to Ruby (#33)
Replace the Swift site generator with a Ruby and Phlex implementation.
Loads site and projects from TOML, derive site metadata from posts.

Migrate from make to bake and add standardrb and code coverage tasks.

Update CI and docs to match the new workflow, and remove unused
assets/dependencies plus obsolete tooling.
2026-02-07 21:19:03 -08:00

484 lines
14 KiB
Ruby

# Build tasks for samhuri.net static site generator
require "etc"
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)
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
WATCHABLE_DIRECTORIES = %w[public posts lib].freeze
LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze
BUILD_TARGETS = %w[debug mudge beta release].freeze
# Generate the site in debug mode (localhost:8000)
def debug
build("http://localhost:8000")
end
# Generate the site for the mudge development server
def mudge
build("http://mudge:8000")
end
# Generate the site for beta/staging
def beta
build("https://beta.samhuri.net")
end
# Generate the site for production
def release
build("https://samhuri.net")
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)
title, filename =
if title_parts.empty?
["Untitled", next_available_draft]
else
given_title = title_parts.join(" ")
slug = slugify(given_title)
abort "Error: title cannot be converted to a filename." if slug.empty?
filename = "#{slug}.md"
path = draft_path(filename)
abort "Error: draft already exists at #{path}" if File.exist?(path)
[given_title, filename]
end
FileUtils.mkdir_p(DRAFTS_DIR)
path = draft_path(filename)
content = render_draft_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)
if input_path.nil? || input_path.strip.empty?
puts "Usage: bake publish_draft <draft-path-or-filename>"
puts
puts "Available drafts:"
drafts = Dir.glob("#{DRAFTS_DIR}/*.md").map { |path| File.basename(path) }
if drafts.empty?
puts " (no drafts found)"
else
drafts.each { |draft| puts " #{draft}" }
end
abort
end
draft_path_value, draft_file = resolve_draft_input(input_path)
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: #{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, or release.
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 to production server
def publish
release
run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true)
end
# Clean generated files
def clean
FileUtils.rm_rf("www")
puts "Cleaned www/ directory"
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 = resolve_coverage_baseline_ref(baseline)
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 = parse_coverage_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)
parse_coverage_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 coverage_script(lowest_count:)
<<~RUBY
require "coverage"
root = Dir.pwd
lib_root = File.join(root, "lib") + "/"
Coverage.start(lines: true)
at_exit do
result = Coverage.result
rows = result.keys
.select { |file| file.start_with?(lib_root) && file.end_with?(".rb") }
.sort
.map do |file|
lines = result[file][:lines] || []
total = 0
covered = 0
lines.each do |line_count|
next if line_count.nil?
total += 1
covered += 1 if line_count.positive?
end
percent = total.zero? ? 100.0 : (covered.to_f / total * 100)
[file, covered, total, percent]
end
covered_lines = rows.sum { |row| row[1] }
total_lines = rows.sum { |row| row[2] }
overall_percent = total_lines.zero? ? 100.0 : (covered_lines.to_f / total_lines * 100)
puts format("Coverage (lib): %.2f%% (%d / %d lines)", overall_percent, covered_lines, total_lines)
unless #{lowest_count}.zero? || rows.empty?
puts "Lowest covered files:"
rows.sort_by { |row| row[3] }.first(#{lowest_count}).each do |file, covered, total, percent|
relative_path = file.delete_prefix(root + "/")
puts format(" %6.2f%% %d/%d %s", percent, covered, total, relative_path)
end
end
end
ARGV.each { |file| require File.expand_path(file) }
RUBY
end
def capture_coverage_output(test_files:, lowest_count:, chdir:)
capture_command("ruby", "-Ilib", "-Itest", "-e", coverage_script(lowest_count:), *test_files, chdir:)
end
def parse_coverage_percent(output)
match = output.match(/Coverage \(lib\):\s+([0-9]+\.[0-9]+)%/)
abort "Error: unable to parse coverage output." unless match
Float(match[1])
end
def resolve_coverage_baseline_ref(baseline)
baseline_name = baseline.to_s.strip
abort "Error: baseline cannot be empty." if baseline_name.empty?
return coverage_merge_base_ref if baseline_name == "merge-base"
baseline_name
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=<ref>." 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
# @parameter url [String] The site URL to use
def build(url)
require "pressa"
puts "Building site for #{url}..."
site = Pressa.create_site(source_path: ".", url_override: url)
generator = Pressa::SiteGenerator.new(site:)
generator.generate(source_path: ".", target_path: "www")
puts "Site built successfully in www/"
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 = ["rsync", "-aKv", "-e", "ssh -4"]
command << "--dry-run" if dry_run
command << "--delete" if delete
command.concat(local_paths)
command << "#{PUBLISH_HOST}:#{publish_dir}"
abort "Error: rsync failed." unless system(*command)
end
def resolve_draft_input(input_path)
if input_path.include?("/")
if input_path.start_with?("posts/")
abort "Error: '#{input_path}' is already published in posts/ directory"
end
[input_path, File.basename(input_path)]
else
[draft_path(input_path), input_path]
end
end
def draft_path(filename)
File.join(DRAFTS_DIR, filename)
end
def slugify(title)
title.downcase
.gsub(/[^a-z0-9\s-]/, "")
.gsub(/\s+/, "-").squeeze("-")
.gsub(/^-|-$/, "")
end
def next_available_draft(base_filename = "untitled.md")
return base_filename unless File.exist?(draft_path(base_filename))
name_without_ext = File.basename(base_filename, ".md")
counter = 1
loop do
numbered_filename = "#{name_without_ext}-#{counter}.md"
return numbered_filename unless File.exist?(draft_path(numbered_filename))
counter += 1
end
end
def render_draft_template(title)
now = Time.now
<<~FRONTMATTER
---
Author: #{current_author}
Title: #{title}
Date: unpublished
Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}
Tags:
---
# #{title}
TKTK
FRONTMATTER
end
def current_author
Etc.getlogin || ENV["USER"] || `whoami`.strip
rescue
ENV["USER"] || `whoami`.strip
end
def ordinal_date(time)
day = time.day
suffix = case day
when 1, 21, 31
"st"
when 2, 22
"nd"
when 3, 23
"rd"
else
"th"
end
time.strftime("#{day}#{suffix} %B, %Y")
end
def command_available?(command)
system("which", command, out: File::NULL, err: File::NULL)
end