From 664a458c3572d403c51539594c1da6ebf07b51ba Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sat, 7 Feb 2026 19:50:19 -0800 Subject: [PATCH] Make plugins explicit and improve coverage workflows --- AGENTS.md | 9 ++- Readme.md | 7 +- bake.rb | 136 ++++++++++++++++++++++++++++++++++--- lib/config/loader.rb | 62 +++++++++++++---- site.toml | 1 + spec/config/loader_test.rb | 90 ++++++++++++++++++++++++ 6 files changed, 278 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f73cf0e..8e4f505 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,8 +24,10 @@ Keep new code under the existing `Pressa` module structure (for example `lib/pos - `rbenv exec bundle exec bake publish_beta|publish`: build and rsync `www/` to remote host. - `rbenv exec bundle exec bake clean`: remove `www/`. - `rbenv exec bundle exec bake test`: run test suite. -- `rbenv exec bundle exec standardrb` or `rbenv exec bundle exec bake lint`: lint code. +- `rbenv exec bundle exec bake lint`: lint code. - `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 new_draft "Post Title"` creates `public/drafts/.md`. @@ -44,7 +46,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`. ## Coding Style & Naming Conventions - Ruby version: `4.0.1` (see `.ruby-version` and `Gemfile`). -- Follow idiomatic Ruby style and keep code `standardrb`-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/`. @@ -54,7 +56,8 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`. - Add regression specs for parser, rendering, feed, and generator behavior changes. - Before submitting, run: - `rbenv exec bundle exec bake test` - - `rbenv exec bundle exec standardrb` + - `rbenv exec bundle exec bake coverage` + - `rbenv exec bundle exec bake lint` - `rbenv exec bundle exec bake debug` ## Commit & Pull Request Guidelines diff --git a/Readme.md b/Readme.md index a248d46..764bdd0 100644 --- a/Readme.md +++ b/Readme.md @@ -43,7 +43,7 @@ rbenv exec bundle exec bake serve # serve www/ locally Site metadata and project data are configured with TOML files at the repository root: -- `site.toml`: site identity, default scripts/styles, and `projects_plugin` assets. +- `site.toml`: site identity, default scripts/styles, and a `plugins` list (for example `["posts", "projects"]`), plus `projects_plugin` assets when that plugin is enabled. - `projects.toml`: project listing entries using `[[projects]]`. `Pressa.create_site` loads both files from the provided `source_path` and still supports URL overrides for `debug`, `beta`, and `release` builds. @@ -53,9 +53,10 @@ Site metadata and project data are configured with TOML files at the repository If this workflow seems like a good fit, here is the minimum to make it your own: - Update `site.toml` with your site identity (`author`, `email`, `title`, `description`, `url`) and any global `scripts` / `styles`. +- Set `plugins` in `site.toml` to explicitly enable features (`"posts"`, `"projects"`). Safe default if omitted is no plugins. - Define your projects in `projects.toml` using `[[projects]]` entries with `name`, `title`, `description`, and `url`. -- Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`). -- Keep the built-in plugins (`Posts::Plugin` and `Projects::Plugin`) or add your own by implementing `Pressa::Plugin` in `lib/` and registering it in `lib/config/loader.rb`. +- Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`) when using the `"projects"` plugin. +- Add custom plugins by implementing `Pressa::Plugin` in `lib/` and registering them in `lib/config/loader.rb`. - Adjust rendering and layout in `lib/views/` and the static content in `public/` as needed. Other targets: diff --git a/bake.rb b/bake.rb index 2904d54..3a6d8d9 100644 --- a/bake.rb +++ b/bake.rb @@ -2,6 +2,8 @@ require "etc" require "fileutils" +require "open3" +require "tmpdir" DRAFTS_DIR = "public/drafts".freeze PUBLISH_HOST = "mudge".freeze @@ -136,9 +138,9 @@ def clean puts "Cleaned www/ directory" end -# Default task: run tests and lint. +# Default task: run coverage and lint. def default - test + coverage lint end @@ -178,6 +180,37 @@ def coverage(lowest: 10) 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) @@ -185,7 +218,19 @@ def run_test_suite(test_files) end def run_coverage(test_files:, lowest_count:) - coverage_script = <<~RUBY + 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("spec/**/*_test.rb").sort } + abort "Error: no tests found in spec/**/*_test.rb under #{chdir}" if test_files.empty? + + test_files +end + +def coverage_script(lowest_count:) + <<~RUBY require "coverage" root = Dir.pwd @@ -226,15 +271,88 @@ def run_coverage(test_files:, lowest_count:) ARGV.each { |file| require File.expand_path(file) } RUBY - - run_command("ruby", "-Ilib", "-Ispec", "-e", coverage_script, *test_files) end -def test_file_list - test_files = Dir.glob("spec/**/*_test.rb").sort - abort "Error: no tests found in spec/**/*_test.rb" if test_files.empty? +def capture_coverage_output(test_files:, lowest_count:, chdir:) + capture_command("ruby", "-Ilib", "-Ispec", "-e", coverage_script(lowest_count:), *test_files, chdir:) +end - test_files +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=." 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 diff --git a/lib/config/loader.rb b/lib/config/loader.rb index b740676..7288f13 100644 --- a/lib/config/loader.rb +++ b/lib/config/loader.rb @@ -18,14 +18,11 @@ module Pressa def build_site(url_override: nil) site_config = load_toml("site.toml") - projects_config = load_toml("projects.toml") validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml") site_url = url_override || site_config["url"] - projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin") - - projects = build_projects(projects_config) + plugins = build_plugins(site_config) Site.new( author: site_config["author"], @@ -36,14 +33,7 @@ module Pressa image_url: normalize_image_url(site_config["image_url"], site_url), scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"), styles: build_styles(site_config["styles"], context: "site.toml styles"), - plugins: [ - Posts::Plugin.new, - Projects::Plugin.new( - projects:, - scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"), - styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles") - ) - ], + plugins:, renderers: [ Utils::MarkdownRenderer.new ] @@ -90,6 +80,54 @@ module Pressa raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}" end + def build_plugins(site_config) + plugin_names = parse_plugin_names(site_config["plugins"]) + + plugin_names.map.with_index do |plugin_name, index| + case plugin_name + when "posts" + Posts::Plugin.new + when "projects" + build_projects_plugin(site_config) + else + raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]" + end + end + end + + def parse_plugin_names(value) + return [] if value.nil? + raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array) + + seen = {} + + value.map.with_index do |plugin_name, index| + unless plugin_name.is_a?(String) && !plugin_name.strip.empty? + raise ValidationError, "Expected site.toml plugins[#{index}] to be a non-empty String" + end + + normalized_plugin_name = plugin_name.strip + if seen[normalized_plugin_name] + raise ValidationError, "Duplicate plugin '#{normalized_plugin_name}' in site.toml plugins" + end + seen[normalized_plugin_name] = true + + normalized_plugin_name + end + end + + def build_projects_plugin(site_config) + projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin") + projects_config = load_toml("projects.toml") + projects = build_projects(projects_config) + + Projects::Plugin.new( + projects:, + scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"), + styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles") + ) + end + def hash_or_empty(value, context) return {} if value.nil? return value if value.is_a?(Hash) diff --git a/site.toml b/site.toml index ff052e8..b9b751d 100644 --- a/site.toml +++ b/site.toml @@ -6,6 +6,7 @@ url = "https://samhuri.net" image_url = "/images/me.jpg" scripts = [] styles = ["css/normalize.css", "css/style.css", "css/syntax.css"] +plugins = ["posts", "projects"] [projects_plugin] scripts = [ diff --git a/spec/config/loader_test.rb b/spec/config/loader_test.rb index 144e890..ec60aa5 100644 --- a/spec/config/loader_test.rb +++ b/spec/config/loader_test.rb @@ -40,6 +40,90 @@ class Pressa::Config::LoaderTest < Minitest::Test end end + def test_build_site_defaults_to_no_plugins_when_plugins_key_is_missing + Dir.mktmpdir do |dir| + File.write(File.join(dir, "site.toml"), <<~TOML) + author = "Sami Samhuri" + email = "sami@samhuri.net" + title = "samhuri.net" + description = "blog" + url = "https://samhuri.net" + TOML + + loader = Pressa::Config::Loader.new(source_path: dir) + site = loader.build_site + assert_empty(site.plugins) + end + end + + def test_build_site_raises_for_invalid_plugins_type + Dir.mktmpdir do |dir| + File.write(File.join(dir, "site.toml"), <<~TOML) + author = "Sami Samhuri" + email = "sami@samhuri.net" + title = "samhuri.net" + description = "blog" + url = "https://samhuri.net" + plugins = "posts" + TOML + + loader = Pressa::Config::Loader.new(source_path: dir) + error = assert_raises(Pressa::Config::ValidationError) { loader.build_site } + assert_match(/Expected site\.toml plugins to be an array/, error.message) + end + end + + def test_build_site_raises_for_unknown_plugin_name + Dir.mktmpdir do |dir| + File.write(File.join(dir, "site.toml"), <<~TOML) + author = "Sami Samhuri" + email = "sami@samhuri.net" + title = "samhuri.net" + description = "blog" + url = "https://samhuri.net" + plugins = ["wat"] + TOML + + loader = Pressa::Config::Loader.new(source_path: dir) + error = assert_raises(Pressa::Config::ValidationError) { loader.build_site } + assert_match(/Unknown plugin 'wat'/, error.message) + end + end + + def test_build_site_raises_for_empty_plugin_name + Dir.mktmpdir do |dir| + File.write(File.join(dir, "site.toml"), <<~TOML) + author = "Sami Samhuri" + email = "sami@samhuri.net" + title = "samhuri.net" + description = "blog" + url = "https://samhuri.net" + plugins = [""] + TOML + + loader = Pressa::Config::Loader.new(source_path: dir) + error = assert_raises(Pressa::Config::ValidationError) { loader.build_site } + assert_match(/Expected site\.toml plugins\[0\] to be a non-empty String/, error.message) + end + end + + def test_build_site_raises_for_duplicate_plugins + Dir.mktmpdir do |dir| + File.write(File.join(dir, "site.toml"), <<~TOML) + author = "Sami Samhuri" + email = "sami@samhuri.net" + title = "samhuri.net" + description = "blog" + url = "https://samhuri.net" + plugins = ["posts", "posts"] + TOML + + loader = Pressa::Config::Loader.new(source_path: dir) + error = assert_raises(Pressa::Config::ValidationError) { loader.build_site } + assert_match(/Duplicate plugin 'posts' in site\.toml plugins/, error.message) + end + end + def test_build_site_raises_for_missing_projects_array Dir.mktmpdir do |dir| File.write(File.join(dir, "site.toml"), <<~TOML) @@ -48,6 +132,7 @@ class Pressa::Config::LoaderTest < Minitest::Test title = "samhuri.net" description = "blog" url = "https://samhuri.net" + plugins = ["projects"] TOML File.write(File.join(dir, "projects.toml"), "title = \"no projects\"\n") @@ -65,6 +150,7 @@ class Pressa::Config::LoaderTest < Minitest::Test title = "samhuri.net" description = "blog" url = "https://samhuri.net" + plugins = ["projects"] TOML File.write(File.join(dir, "projects.toml"), <<~TOML) projects = [1] @@ -84,6 +170,7 @@ class Pressa::Config::LoaderTest < Minitest::Test title = "samhuri.net" description = "blog" url = "https://samhuri.net" + plugins = ["projects"] projects_plugin = [] TOML File.write(File.join(dir, "projects.toml"), <<~TOML) @@ -148,6 +235,7 @@ class Pressa::Config::LoaderTest < Minitest::Test image_url = "https://images.example.net/me.jpg" scripts = [{"src": "/js/site.js", "defer": false}] styles = [{"href": "/css/site.css"}] + plugins = ["posts", "projects"] [projects_plugin] scripts = [{"src": "/js/projects.js", "defer": true}] @@ -240,6 +328,7 @@ class Pressa::Config::LoaderTest < Minitest::Test title = "samhuri.net" description = "blog" url = "https://samhuri.net" + plugins = ["projects"] [projects_plugin] scripts = "js/projects.js" @@ -262,6 +351,7 @@ class Pressa::Config::LoaderTest < Minitest::Test image_url = "/images/me.jpg" scripts = [] styles = ["css/style.css"] + plugins = ["posts", "projects"] [projects_plugin] scripts = ["js/projects.js"]