diff --git a/lib/perron/resource.rb b/lib/perron/resource.rb index 9373401..0da4bc0 100644 --- a/lib/perron/resource.rb +++ b/lib/perron/resource.rb @@ -19,6 +19,7 @@ require "perron/resource/sweeper" require "perron/resource/adjacency" require "perron/resource/table_of_content" +require "perron/resource/updatable" module Perron class Resource diff --git a/lib/perron/resource/updatable.rb b/lib/perron/resource/updatable.rb new file mode 100644 index 0000000..295103f --- /dev/null +++ b/lib/perron/resource/updatable.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Perron + class Resource + module Updatable + extend ActiveSupport::Concern + + included do + def metadata + @metadata ||= Proxy.new(super, self) + end + end + + class Proxy + def initialize(metadata, resource) + @metadata = metadata + @resource = resource + end + + def update(attributes) + attributes = attributes.deep_symbolize_keys + + separator = Perron::Resource::Separator.new(@resource.raw_content) + frontmatter = separator.frontmatter.to_h.merge(attributes) + yaml = frontmatter.transform_keys(&:to_s).to_yaml.sub(/\A---\n/, "") + body = separator.content + + File.write(@resource.file_path, "---\n#{yaml}---\n\n#{body}\n") + + @resource.instance_variable_set(:@frontmatter, nil) + attributes.each { |key, value| @metadata[key] = value } + + self + end + + def respond_to_missing?(name, include_private = false) + @metadata.respond_to?(name, include_private) || super + end + + def method_missing(name, *arguments, &block) + @metadata.public_send(name, *arguments, &block) + end + end + end + end +end diff --git a/test/dummy/app/models/content/post.rb b/test/dummy/app/models/content/post.rb index 2df26e8..b7c2864 100644 --- a/test/dummy/app/models/content/post.rb +++ b/test/dummy/app/models/content/post.rb @@ -1,8 +1,10 @@ class Content::Post < Perron::Resource + include Perron::Resource::Updatable + CATEGORIES = { - ruby: { title: "Ruby", description: "Ruby articles" }, - rails: { title: "Rails", description: "Rails articles" }, - css: { title: "CSS", description: "CSS articles" } + ruby: {title: "Ruby", description: "Ruby articles"}, + rails: {title: "Rails", description: "Rails articles"}, + css: {title: "CSS", description: "CSS articles"} } configure do |config| diff --git a/test/perron/resource/updatable_test.rb b/test/perron/resource/updatable_test.rb new file mode 100644 index 0000000..455b675 --- /dev/null +++ b/test/perron/resource/updatable_test.rb @@ -0,0 +1,85 @@ +require "test_helper" + +class Perron::Resource::UpdatableTest < ActiveSupport::TestCase + FIXTURE = "test/dummy/app/content/posts/2023-05-15-sample-post.md" + + def setup + @tmp = Rails.root.join("tmp/updatable_metadata_#{SecureRandom.hex(4)}.md").to_s + FileUtils.cp(FIXTURE, @tmp) + @post = Content::Post.new(@tmp) + end + + def teardown + FileUtils.rm_f(@tmp) + end + + def original_frontmatter + Perron::Resource::Separator.new(File.read(FIXTURE)).frontmatter.to_h + end + + def current_frontmatter + Perron::Resource::Separator.new(File.read(@tmp)).frontmatter.to_h + end + + def original_body + Perron::Resource::Separator.new(File.read(FIXTURE)).content + end + + def current_body + Perron::Resource::Separator.new(File.read(@tmp)).content + end + + test "updating an existing key changes only that key" do + @post.metadata.update(title: "Changed Title") + + updated = current_frontmatter + original = original_frontmatter + + assert_equal "Changed Title", updated[:title] + assert_equal original.except(:title), updated.except(:title) + assert_equal original_body, current_body + end + + test "only the changed key line differs in the file" do + @post.metadata.update(title: "Changed Title") + + original_lines = File.readlines(FIXTURE) + updated_lines = File.readlines(@tmp) + + diff = original_lines.zip(updated_lines).reject { |a, b| a == b } + assert_equal 1, diff.size + assert_match(/^title: Changed Title$/, diff.first.last) + end + + test "updating a new key inserts it without disturbing others" do + @post.metadata.update(published_at: Date.new(2024, 1, 1)) + + updated = current_frontmatter + original = original_frontmatter + + assert_equal Date.new(2024, 1, 1), updated[:published_at] + assert_equal original, updated.except(:published_at) + assert_equal original_body, current_body + end + + test "multiline values round-trip without altering other keys" do + @post.metadata.update(description: "line one\nline two") + + updated = current_frontmatter + original = original_frontmatter + + assert_equal "line one\nline two", updated[:description] + assert_equal original.except(:description), updated.except(:description) + assert_equal original_body, current_body + end + + test "in-memory metadata reflects the update immediately" do + @post.metadata.update(title: "In Memory") + + assert_equal "In Memory", @post.metadata.title + end + + test "update returns self for chaining" do + assert_equal @post.metadata, @post.metadata.update(title: "X") + end +end