mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-06-23 04:44:54 +00:00
Extract bake logic into lib/pressa and fold tasks back into one bake.rb
Pull the pure, testable logic out of the bake tasks into Pressa modules: - Pressa::Drafts — slugs, ordinal dates, draft paths, and the new-draft template - Pressa::Coverage — the coverage measurement script, output parsing, and baseline-ref resolution - Pressa::Publish — the rsync command builder Each gets its own tests, so this logic now sits under the lib/ coverage gate instead of being untested glue inside the task files. With the logic extracted, the namespaced bake/ split no longer earns its keep — those files were mostly thin task declarations. Collapse build/draft/publish/quality back into a single bake.rb with the original flat task names (new_draft, coverage, publish_beta, and so on) and update CI and AGENTS.md to match.
This commit is contained in:
parent
a088b8dab2
commit
fc68fc58af
14 changed files with 753 additions and 531 deletions
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
run: bin/bootstrap
|
||||
|
||||
- name: Coverage
|
||||
run: bundle exec bake quality:coverage
|
||||
run: bundle exec bake coverage
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
run: bin/bootstrap
|
||||
|
||||
- name: Lint
|
||||
run: bundle exec bake quality:lint
|
||||
run: bundle exec bake lint
|
||||
|
||||
debug:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -62,4 +62,4 @@ jobs:
|
|||
run: bin/bootstrap
|
||||
|
||||
- name: Debug Build
|
||||
run: bundle exec bake build:debug
|
||||
run: bundle exec bake debug
|
||||
|
|
|
|||
52
AGENTS.md
52
AGENTS.md
|
|
@ -4,7 +4,7 @@
|
|||
This repository is a Ruby static-site generator (Pressa) that outputs both HTML and Gemini formats.
|
||||
|
||||
- Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
|
||||
- Build/publish/draft tasks: `bake.rb` (root) + `bake/` (namespaced task files)
|
||||
- Build/publish/draft tasks: `bake.rb` (delegating to helpers under `lib/pressa/`)
|
||||
- Tests: `test/`
|
||||
- Site config: `site.toml`, `projects.toml`
|
||||
- Published posts: `posts/YYYY/MM/*.md`
|
||||
|
|
@ -20,26 +20,26 @@ Keep new code under the existing `Pressa` module structure (for example `lib/pre
|
|||
## Setup, Build, Test, and Development Commands
|
||||
- Use `rbenv exec` for Ruby commands in this repository (for example `rbenv exec bundle exec ...`) to ensure the project Ruby version is used.
|
||||
- `bin/bootstrap`: install prerequisites and gems (uses `rbenv` when available).
|
||||
- `rbenv exec bundle exec bake build:debug`: build HTML for `http://localhost:8000` into `www/`.
|
||||
- `rbenv exec bundle exec bake build:serve`: serve `www/` via WEBrick on port 8000.
|
||||
- `rbenv exec bundle exec bake build:watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
|
||||
- `rbenv exec bundle exec bake build:mudge|build:beta|build:release`: build HTML with environment-specific base URLs.
|
||||
- `rbenv exec bundle exec bake build:gemini`: build Gemini capsule into `gemini/`.
|
||||
- `rbenv exec bundle exec bake publish:beta`: build and rsync `www/` to beta host.
|
||||
- `rbenv exec bundle exec bake publish:gemini`: build and rsync `gemini/` to production host.
|
||||
- `rbenv exec bundle exec bake publish:production`: build and rsync both HTML and Gemini to production.
|
||||
- `rbenv exec bundle exec bake build:clean`: remove `www/` and `gemini/`.
|
||||
- `rbenv exec bundle exec bake quality:test`: run test suite.
|
||||
- `rbenv exec bundle exec bake quality:guard`: run Guard for continuous testing.
|
||||
- `rbenv exec bundle exec bake quality:lint`: lint code with StandardRB.
|
||||
- `rbenv exec bundle exec bake quality:lint_fix`: auto-fix lint issues.
|
||||
- `rbenv exec bundle exec bake quality:coverage`: run tests and report `lib/` line coverage.
|
||||
- `rbenv exec bundle exec bake quality:coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed).
|
||||
- `rbenv exec bundle exec bake debug`: build HTML for `http://localhost:8000` into `www/`.
|
||||
- `rbenv exec bundle exec bake serve`: serve `www/` via WEBrick on port 8000.
|
||||
- `rbenv exec bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
|
||||
- `rbenv exec bundle exec bake mudge|beta|release`: build HTML with environment-specific base URLs.
|
||||
- `rbenv exec bundle exec bake gemini`: build Gemini capsule into `gemini/`.
|
||||
- `rbenv exec bundle exec bake publish_beta`: build and rsync `www/` to beta host.
|
||||
- `rbenv exec bundle exec bake publish_gemini`: build and rsync `gemini/` to production host.
|
||||
- `rbenv exec bundle exec bake publish`: build and rsync both HTML and Gemini to production.
|
||||
- `rbenv exec bundle exec bake clean`: remove `www/` and `gemini/`.
|
||||
- `rbenv exec bundle exec bake test`: run test suite.
|
||||
- `rbenv exec bundle exec bake guard`: run Guard for continuous testing.
|
||||
- `rbenv exec bundle exec bake lint`: lint code with StandardRB.
|
||||
- `rbenv exec bundle exec bake lint_fix`: auto-fix lint issues.
|
||||
- `rbenv exec bundle exec bake coverage`: run tests and report `lib/` line coverage.
|
||||
- `rbenv exec bundle exec bake coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed).
|
||||
|
||||
## Draft Workflow
|
||||
- `rbenv exec bundle exec bake draft:new "Post Title"` creates `public/drafts/<slug>.md`.
|
||||
- `rbenv exec bundle exec bake draft:list` lists available drafts.
|
||||
- `rbenv exec bundle exec bake draft:publish public/drafts/<slug>.md` moves draft to `posts/YYYY/MM/` and updates `Date` and `Timestamp`.
|
||||
- `rbenv exec bundle exec bake new_draft "Post Title"` creates `public/drafts/<slug>.md`.
|
||||
- `rbenv exec bundle exec bake drafts` lists available drafts.
|
||||
- `rbenv exec bundle exec bake publish_draft public/drafts/<slug>.md` moves draft to `posts/YYYY/MM/` and updates `Date` and `Timestamp`.
|
||||
|
||||
## Content and Metadata Requirements
|
||||
Posts must include YAML front matter. Required keys (enforced by `Pressa::Posts::PostMetadata`) are:
|
||||
|
|
@ -53,7 +53,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
|
|||
|
||||
## Coding Style & Naming Conventions
|
||||
- Ruby (see `.ruby-version`).
|
||||
- Follow idiomatic Ruby style and keep code `bake quality:lint`-clean.
|
||||
- Follow idiomatic Ruby style and keep code `bake lint`-clean.
|
||||
- Use 2-space indentation and descriptive `snake_case` names for methods/variables, `UpperCamelCase` for classes/modules.
|
||||
- Prefer small, focused classes for plugins, views, renderers, and config loaders.
|
||||
- Do not hand-edit generated files in `www/` or `gemini/`.
|
||||
|
|
@ -62,10 +62,10 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
|
|||
- Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`).
|
||||
- Add regression tests for parser, rendering, feed, and generator behavior changes.
|
||||
- Before submitting, run:
|
||||
- `rbenv exec bundle exec bake quality:test`
|
||||
- `rbenv exec bundle exec bake quality:coverage`
|
||||
- `rbenv exec bundle exec bake quality:lint`
|
||||
- `rbenv exec bundle exec bake build:debug`
|
||||
- `rbenv exec bundle exec bake test`
|
||||
- `rbenv exec bundle exec bake coverage`
|
||||
- `rbenv exec bundle exec bake lint`
|
||||
- `rbenv exec bundle exec bake debug`
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Use concise, imperative commit subjects (history examples: `Fix internal permalink regression in archives`).
|
||||
|
|
@ -74,11 +74,11 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
|
|||
- Include screenshots when changing rendered layout/CSS output.
|
||||
|
||||
## Deployment & Security Notes
|
||||
- Publish tasks are defined in `bake/publish.rb` via rsync over SSH.
|
||||
- Publish tasks are defined in `bake.rb` via rsync over SSH.
|
||||
- Current publish host is `mudge` with:
|
||||
- production HTML: `/var/www/samhuri.net/public`
|
||||
- beta HTML: `/var/www/beta.samhuri.net/public`
|
||||
- production Gemini: `/var/gemini/samhuri.net`
|
||||
- `bake publish:production` deploys both HTML and Gemini to production.
|
||||
- `bake publish` deploys both HTML and Gemini to production.
|
||||
- Validate `www/` and `gemini/` before publishing to avoid shipping stale assets.
|
||||
- Never commit credentials, SSH keys, or other secrets.
|
||||
|
|
|
|||
369
bake.rb
369
bake.rb
|
|
@ -1,8 +1,373 @@
|
|||
# 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 <draft-path-or-filename>"
|
||||
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
|
||||
call("quality:coverage")
|
||||
call("quality:lint")
|
||||
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=<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 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
|
||||
|
|
|
|||
|
|
@ -1,93 +0,0 @@
|
|||
require "fileutils"
|
||||
|
||||
WATCHABLE_DIRECTORIES = %w[public posts lib].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
|
||||
|
||||
# Clean generated files
|
||||
def clean
|
||||
FileUtils.rm_rf("www")
|
||||
FileUtils.rm_rf("gemini")
|
||||
puts "Cleaned www/ and gemini/ directories"
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
# 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 command_available?(command)
|
||||
system("which", command, out: File::NULL, err: File::NULL)
|
||||
end
|
||||
152
bake/draft.rb
152
bake/draft.rb
|
|
@ -1,152 +0,0 @@
|
|||
require "etc"
|
||||
require "fileutils"
|
||||
|
||||
DRAFTS_DIR = "public/drafts".freeze
|
||||
|
||||
# Create a new draft in public/drafts/.
|
||||
# @parameter title_parts [Array] Optional title words; defaults to Untitled.
|
||||
def new(*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(input_path = nil)
|
||||
if input_path.nil? || input_path.strip.empty?
|
||||
puts "Usage: bake draft:publish <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
|
||||
|
||||
# List all available drafts
|
||||
def list
|
||||
Dir.glob("#{DRAFTS_DIR}/*.md").sort.each do |draft|
|
||||
puts File.basename(draft)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
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
|
||||
|
||||
# Publish to beta/staging server
|
||||
def beta
|
||||
call("build:beta")
|
||||
run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true)
|
||||
end
|
||||
|
||||
# Publish Gemini capsule to production
|
||||
def gemini
|
||||
call("build:gemini")
|
||||
run_rsync(local_paths: ["gemini/"], publish_dir: GEMINI_PUBLISH_DIR, dry_run: false, delete: true)
|
||||
end
|
||||
|
||||
# Publish to production server
|
||||
def production
|
||||
call("build:release")
|
||||
run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true)
|
||||
call("publish:gemini")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
221
bake/quality.rb
221
bake/quality.rb
|
|
@ -1,221 +0,0 @@
|
|||
require "fileutils"
|
||||
require "open3"
|
||||
require "tmpdir"
|
||||
|
||||
LINT_TARGETS = %w[bake.rb Gemfile bake lib test].freeze
|
||||
|
||||
# Run Minitest tests
|
||||
def test
|
||||
run_test_suite(test_file_list)
|
||||
end
|
||||
|
||||
# Run Guard for continuous testing
|
||||
def guard
|
||||
exec "bundle exec guard"
|
||||
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
|
||||
|
||||
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
|
||||
|
|
@ -6,6 +6,9 @@ require "pressa/projects/plugin"
|
|||
require "pressa/utils/markdown_renderer"
|
||||
require "pressa/utils/gemini_markdown_renderer"
|
||||
require "pressa/config/loader"
|
||||
require "pressa/drafts"
|
||||
require "pressa/coverage"
|
||||
require "pressa/publish"
|
||||
|
||||
module Pressa
|
||||
def self.create_site(source_path: ".", url_override: nil, output_format: "html")
|
||||
|
|
|
|||
72
lib/pressa/coverage.rb
Normal file
72
lib/pressa/coverage.rb
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
module Pressa
|
||||
# Pure helpers for the coverage tasks: the in-subprocess measurement script,
|
||||
# parsing its output, and resolving a baseline ref. The git plumbing that
|
||||
# actually computes a merge-base stays in the bake tasks that call this.
|
||||
module Coverage
|
||||
class Error < StandardError; end
|
||||
|
||||
module_function
|
||||
|
||||
def parse_percent(output)
|
||||
match = output.match(/Coverage \(lib\):\s+([0-9]+\.[0-9]+)%/)
|
||||
raise Error, "unable to parse coverage output." unless match
|
||||
|
||||
Float(match[1])
|
||||
end
|
||||
|
||||
# Resolve a baseline ref. Yields (and returns the block's value) only when
|
||||
# the baseline is "merge-base", so the git work is deferred to the caller.
|
||||
def resolve_baseline_ref(baseline)
|
||||
name = baseline.to_s.strip
|
||||
raise Error, "baseline cannot be empty." if name.empty?
|
||||
|
||||
return yield if name == "merge-base"
|
||||
|
||||
name
|
||||
end
|
||||
|
||||
def 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
|
||||
end
|
||||
end
|
||||
90
lib/pressa/drafts.rb
Normal file
90
lib/pressa/drafts.rb
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
require "etc"
|
||||
|
||||
module Pressa
|
||||
# Draft authoring logic: slugs, paths, ordinal dates, and the new-draft template.
|
||||
# File mutation (writing, moving, date rewriting) stays in the bake tasks that call this.
|
||||
class Drafts
|
||||
class Error < StandardError; end
|
||||
|
||||
DEFAULT_DIR = "public/drafts".freeze
|
||||
|
||||
def self.slugify(title)
|
||||
title.downcase
|
||||
.gsub(/[^a-z0-9\s-]/, "")
|
||||
.gsub(/\s+/, "-").squeeze("-")
|
||||
.gsub(/^-|-$/, "")
|
||||
end
|
||||
|
||||
def self.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 self.current_author
|
||||
Etc.getlogin || ENV["USER"] || `whoami`.strip
|
||||
rescue
|
||||
ENV["USER"] || `whoami`.strip
|
||||
end
|
||||
|
||||
def initialize(dir: DEFAULT_DIR, exists: ->(path) { File.exist?(path) })
|
||||
@dir = dir
|
||||
@exists = exists
|
||||
end
|
||||
|
||||
def path(filename)
|
||||
File.join(@dir, filename)
|
||||
end
|
||||
|
||||
def next_available(base_filename = "untitled.md")
|
||||
return base_filename unless @exists.call(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 @exists.call(path(numbered_filename))
|
||||
|
||||
counter += 1
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_input(input_path)
|
||||
if input_path.include?("/")
|
||||
if input_path.start_with?("posts/")
|
||||
raise Error, "'#{input_path}' is already published in posts/ directory"
|
||||
end
|
||||
|
||||
[input_path, File.basename(input_path)]
|
||||
else
|
||||
[path(input_path), input_path]
|
||||
end
|
||||
end
|
||||
|
||||
def render_template(title, author: self.class.current_author, now: Time.now)
|
||||
<<~FRONTMATTER
|
||||
---
|
||||
Author: #{author}
|
||||
Title: #{title}
|
||||
Date: unpublished
|
||||
Timestamp: #{now.strftime("%Y-%m-%dT%H:%M:%S%:z")}
|
||||
Tags:
|
||||
---
|
||||
|
||||
# #{title}
|
||||
|
||||
TKTK
|
||||
FRONTMATTER
|
||||
end
|
||||
end
|
||||
end
|
||||
16
lib/pressa/publish.rb
Normal file
16
lib/pressa/publish.rb
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
module Pressa
|
||||
# Builds the rsync invocation used to deploy generated output to a host.
|
||||
# The bake publish tasks own the actual process spawn; this just assembles argv.
|
||||
module Publish
|
||||
module_function
|
||||
|
||||
def rsync_command(local_paths:, host:, 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 << "#{host}:#{publish_dir}"
|
||||
command
|
||||
end
|
||||
end
|
||||
end
|
||||
41
test/coverage_test.rb
Normal file
41
test/coverage_test.rb
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
require "test_helper"
|
||||
|
||||
class Pressa::CoverageTest < Minitest::Test
|
||||
def test_parse_percent_extracts_the_lib_coverage_figure
|
||||
output = "Coverage (lib): 98.89% (1597 / 1615 lines)\nLowest covered files:\n"
|
||||
assert_in_delta(98.89, Pressa::Coverage.parse_percent(output), 0.0001)
|
||||
end
|
||||
|
||||
def test_parse_percent_raises_when_the_marker_is_missing
|
||||
error = assert_raises(Pressa::Coverage::Error) { Pressa::Coverage.parse_percent("nothing useful here") }
|
||||
assert_match(/unable to parse/, error.message)
|
||||
end
|
||||
|
||||
def test_script_filters_to_lib_and_reports_the_lib_marker
|
||||
script = Pressa::Coverage.script(lowest_count: 10)
|
||||
assert_match(/lib_root/, script)
|
||||
assert_match(/Coverage \(lib\):/, script)
|
||||
end
|
||||
|
||||
def test_script_interpolates_the_lowest_count
|
||||
assert_match(/unless 0\.zero\?/, Pressa::Coverage.script(lowest_count: 0))
|
||||
assert_match(/unless 5\.zero\?/, Pressa::Coverage.script(lowest_count: 5))
|
||||
end
|
||||
|
||||
def test_resolve_baseline_ref_returns_explicit_ref_without_yielding
|
||||
yielded = false
|
||||
ref = Pressa::Coverage.resolve_baseline_ref("v1.2.3") { yielded = true }
|
||||
assert_equal("v1.2.3", ref)
|
||||
refute(yielded, "merge-base block should not run for an explicit ref")
|
||||
end
|
||||
|
||||
def test_resolve_baseline_ref_yields_for_merge_base
|
||||
ref = Pressa::Coverage.resolve_baseline_ref("merge-base") { "abc1234" }
|
||||
assert_equal("abc1234", ref)
|
||||
end
|
||||
|
||||
def test_resolve_baseline_ref_rejects_blank_baselines
|
||||
error = assert_raises(Pressa::Coverage::Error) { Pressa::Coverage.resolve_baseline_ref(" ") {} }
|
||||
assert_match(/cannot be empty/, error.message)
|
||||
end
|
||||
end
|
||||
93
test/drafts_test.rb
Normal file
93
test/drafts_test.rb
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
require "test_helper"
|
||||
|
||||
class Pressa::DraftsTest < Minitest::Test
|
||||
def test_slugify_lowercases_and_hyphenates
|
||||
assert_equal("powder-day-at-baker", Pressa::Drafts.slugify("Powder Day at Baker"))
|
||||
end
|
||||
|
||||
def test_slugify_strips_symbols_and_squeezes_separators
|
||||
assert_equal("nine-inch-nails-the-fragile", Pressa::Drafts.slugify("Nine Inch Nails: The Fragile!!!"))
|
||||
end
|
||||
|
||||
def test_slugify_trims_leading_and_trailing_hyphens
|
||||
assert_equal("hello", Pressa::Drafts.slugify(" ...Hello... "))
|
||||
end
|
||||
|
||||
def test_slugify_returns_empty_string_for_symbol_only_titles
|
||||
assert_equal("", Pressa::Drafts.slugify("!!!"))
|
||||
end
|
||||
|
||||
def test_ordinal_date_uses_st_nd_rd_suffixes
|
||||
assert_equal("1st June, 2026", Pressa::Drafts.ordinal_date(Time.new(2026, 6, 1)))
|
||||
assert_equal("2nd June, 2026", Pressa::Drafts.ordinal_date(Time.new(2026, 6, 2)))
|
||||
assert_equal("3rd June, 2026", Pressa::Drafts.ordinal_date(Time.new(2026, 6, 3)))
|
||||
assert_equal("21st June, 2026", Pressa::Drafts.ordinal_date(Time.new(2026, 6, 21)))
|
||||
assert_equal("31st March, 2026", Pressa::Drafts.ordinal_date(Time.new(2026, 3, 31)))
|
||||
end
|
||||
|
||||
def test_ordinal_date_uses_th_for_the_teens
|
||||
assert_equal("11th June, 2026", Pressa::Drafts.ordinal_date(Time.new(2026, 6, 11)))
|
||||
assert_equal("12th June, 2026", Pressa::Drafts.ordinal_date(Time.new(2026, 6, 12)))
|
||||
assert_equal("13th June, 2026", Pressa::Drafts.ordinal_date(Time.new(2026, 6, 13)))
|
||||
end
|
||||
|
||||
def test_path_joins_filename_with_drafts_dir
|
||||
drafts = Pressa::Drafts.new(dir: "public/drafts")
|
||||
assert_equal("public/drafts/the-fragile.md", drafts.path("the-fragile.md"))
|
||||
end
|
||||
|
||||
def test_next_available_returns_base_filename_when_free
|
||||
drafts = Pressa::Drafts.new(dir: "public/drafts", exists: ->(_path) { false })
|
||||
assert_equal("untitled.md", drafts.next_available)
|
||||
end
|
||||
|
||||
def test_next_available_appends_counter_on_collision
|
||||
taken = ["public/drafts/untitled.md", "public/drafts/untitled-1.md"]
|
||||
drafts = Pressa::Drafts.new(dir: "public/drafts", exists: ->(path) { taken.include?(path) })
|
||||
assert_equal("untitled-2.md", drafts.next_available)
|
||||
end
|
||||
|
||||
def test_resolve_input_treats_bare_name_as_draft_filename
|
||||
drafts = Pressa::Drafts.new(dir: "public/drafts")
|
||||
assert_equal(["public/drafts/the-fragile.md", "the-fragile.md"], drafts.resolve_input("the-fragile.md"))
|
||||
end
|
||||
|
||||
def test_resolve_input_uses_explicit_path_verbatim
|
||||
drafts = Pressa::Drafts.new(dir: "public/drafts")
|
||||
assert_equal(
|
||||
["public/drafts/the-fragile.md", "the-fragile.md"],
|
||||
drafts.resolve_input("public/drafts/the-fragile.md")
|
||||
)
|
||||
end
|
||||
|
||||
def test_resolve_input_rejects_already_published_paths
|
||||
drafts = Pressa::Drafts.new(dir: "public/drafts")
|
||||
error = assert_raises(Pressa::Drafts::Error) { drafts.resolve_input("posts/2026/06/the-fragile.md") }
|
||||
assert_match(/already published/, error.message)
|
||||
end
|
||||
|
||||
def test_current_author_returns_a_non_empty_login
|
||||
assert_kind_of(String, Pressa::Drafts.current_author)
|
||||
refute_empty(Pressa::Drafts.current_author)
|
||||
end
|
||||
|
||||
def test_current_author_falls_back_when_getlogin_raises
|
||||
original = Etc.method(:getlogin)
|
||||
Etc.define_singleton_method(:getlogin) { raise "no login on this box" }
|
||||
assert_kind_of(String, Pressa::Drafts.current_author)
|
||||
refute_empty(Pressa::Drafts.current_author)
|
||||
ensure
|
||||
Etc.define_singleton_method(:getlogin, original)
|
||||
end
|
||||
|
||||
def test_render_template_includes_front_matter_and_title
|
||||
drafts = Pressa::Drafts.new(dir: "public/drafts")
|
||||
content = drafts.render_template("The Downward Spiral", author: "Trent Reznor", now: Time.new(2026, 6, 7, 9, 30, 0))
|
||||
|
||||
assert_match(/^Author: Trent Reznor$/, content)
|
||||
assert_match(/^Title: The Downward Spiral$/, content)
|
||||
assert_match(/^Date: unpublished$/, content)
|
||||
assert_match(/^Timestamp: 2026-06-07T09:30:00/, content)
|
||||
assert_match(/^# The Downward Spiral$/, content)
|
||||
end
|
||||
end
|
||||
42
test/publish_test.rb
Normal file
42
test/publish_test.rb
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
require "test_helper"
|
||||
|
||||
class Pressa::PublishTest < Minitest::Test
|
||||
def test_rsync_command_builds_the_base_invocation
|
||||
command = Pressa::Publish.rsync_command(
|
||||
local_paths: ["www/"], host: "mudge", publish_dir: "/var/www/samhuri.net/public",
|
||||
dry_run: false, delete: false
|
||||
)
|
||||
assert_equal(
|
||||
["rsync", "-aKv", "-e", "ssh -4", "www/", "mudge:/var/www/samhuri.net/public"],
|
||||
command
|
||||
)
|
||||
end
|
||||
|
||||
def test_rsync_command_adds_dry_run_and_delete_flags
|
||||
command = Pressa::Publish.rsync_command(
|
||||
local_paths: ["gemini/"], host: "mudge", publish_dir: "/var/gemini/samhuri.net",
|
||||
dry_run: true, delete: true
|
||||
)
|
||||
assert_includes(command, "--dry-run")
|
||||
assert_includes(command, "--delete")
|
||||
end
|
||||
|
||||
def test_rsync_command_omits_flags_when_disabled
|
||||
command = Pressa::Publish.rsync_command(
|
||||
local_paths: ["www/"], host: "mudge", publish_dir: "/tmp/site",
|
||||
dry_run: false, delete: false
|
||||
)
|
||||
refute_includes(command, "--dry-run")
|
||||
refute_includes(command, "--delete")
|
||||
end
|
||||
|
||||
def test_rsync_command_supports_multiple_local_paths
|
||||
command = Pressa::Publish.rsync_command(
|
||||
local_paths: ["www/", "extra/"], host: "powder", publish_dir: "/srv/site",
|
||||
dry_run: false, delete: true
|
||||
)
|
||||
assert_equal("powder:/srv/site", command.last)
|
||||
assert_includes(command, "www/")
|
||||
assert_includes(command, "extra/")
|
||||
end
|
||||
end
|
||||
Loading…
Reference in a new issue