diff --git a/lib/pressa/config/loader.rb b/lib/pressa/config/loader.rb index bc56b15..bf11540 100644 --- a/lib/pressa/config/loader.rb +++ b/lib/pressa/config/loader.rb @@ -141,10 +141,18 @@ module Pressa entries.map.with_index do |item, index| case item when String + validate_asset_path!( + item, + context: "#{context}[#{index}]" + ) Script.new(src: item, defer: true) when Hash src = item["src"] raise ValidationError, "Expected #{context}[#{index}].src to be a String" unless src.is_a?(String) && !src.empty? + validate_asset_path!( + src, + context: "#{context}[#{index}].src" + ) defer = item.key?("defer") ? item["defer"] : true unless [true, false].include?(defer) @@ -164,10 +172,18 @@ module Pressa entries.map.with_index do |item, index| case item when String + validate_asset_path!( + item, + context: "#{context}[#{index}]" + ) Stylesheet.new(href: item) when Hash href = item["href"] raise ValidationError, "Expected #{context}[#{index}].href to be a String" unless href.is_a?(String) && !href.empty? + validate_asset_path!( + href, + context: "#{context}[#{index}].href" + ) Stylesheet.new(href:) else @@ -190,6 +206,12 @@ module Pressa normalized = value.start_with?("/") ? value : "/#{value}" "#{site_url}#{normalized}" end + + def validate_asset_path!(value, context:) + return if value.start_with?("/", "http://", "https://") + + raise ValidationError, "Expected #{context} to start with / or use http(s) scheme" + end end end end diff --git a/lib/pressa/posts/models.rb b/lib/pressa/posts/models.rb index 2e90959..d95d63e 100644 --- a/lib/pressa/posts/models.rb +++ b/lib/pressa/posts/models.rb @@ -86,6 +86,10 @@ module Pressa def recent_posts(limit = 10) all_posts.take(limit) end + + def earliest_year + by_year.keys.min + end end end end diff --git a/lib/pressa/site.rb b/lib/pressa/site.rb index 8020965..99899d4 100644 --- a/lib/pressa/site.rb +++ b/lib/pressa/site.rb @@ -21,6 +21,7 @@ module Pressa attribute :description, Types::String attribute :url, Types::String attribute :image_url, Types::String.optional.default(nil) + attribute :copyright_start_year, Types::Integer.optional.default(nil) attribute :scripts, Types::Array.of(Script).default([].freeze) attribute :styles, Types::Array.of(Stylesheet).default([].freeze) attribute :plugins, Types::Array.default([].freeze) diff --git a/lib/pressa/site_generator.rb b/lib/pressa/site_generator.rb index 77b9d79..bc7aa6b 100644 --- a/lib/pressa/site_generator.rb +++ b/lib/pressa/site_generator.rb @@ -15,8 +15,10 @@ module Pressa FileUtils.rm_rf(target_path) FileUtils.mkdir_p(target_path) - site.plugins.each { |plugin| plugin.setup(site:, source_path:) } + setup_site = site + setup_site.plugins.each { |plugin| plugin.setup(site: setup_site, source_path:) } + @site = site_with_copyright_start_year(setup_site) site.plugins.each { |plugin| plugin.render(site:, target_path:) } copy_static_files(source_path, target_path) @@ -99,5 +101,23 @@ module Pressa basename = File.basename(source_file) basename.start_with?(".") end + + def site_with_copyright_start_year(base_site) + start_year = find_copyright_start_year(base_site) + Site.new(**base_site.to_h.merge(copyright_start_year: start_year)) + end + + def find_copyright_start_year(base_site) + years = base_site.plugins.filter_map do |plugin| + next unless plugin.respond_to?(:posts_by_year) + + posts_by_year = plugin.posts_by_year + next unless posts_by_year.respond_to?(:earliest_year) + + posts_by_year.earliest_year + end + + years.min || Time.now.year + end end end diff --git a/lib/pressa/views/layout.rb b/lib/pressa/views/layout.rb index 40a1069..28dddcd 100644 --- a/lib/pressa/views/layout.rb +++ b/lib/pressa/views/layout.rb @@ -4,8 +4,6 @@ require "pressa/views/icons" module Pressa module Views class Layout < Phlex::HTML - START_YEAR = 2006 - attr_reader :site, :page_subtitle, :page_description, @@ -34,10 +32,6 @@ module Pressa @content = content end - def format_output? - true - end - def view_template doctype @@ -172,7 +166,7 @@ module Pressa def render_footer footer do - plain "© #{START_YEAR} - #{Time.now.year} " + plain "© #{footer_years} " a(href: site.url_for("/about")) { site.author } end end @@ -201,6 +195,14 @@ module Pressa normalized = path.start_with?("/") ? path : "/#{path}" site.url_for(normalized) end + + def footer_years + current_year = Time.now.year + start_year = site.copyright_start_year || current_year + return current_year.to_s if start_year >= current_year + + "#{start_year} - #{current_year}" + end end end end diff --git a/site.toml b/site.toml index b9b751d..6c906e3 100644 --- a/site.toml +++ b/site.toml @@ -5,14 +5,14 @@ description = "Sami Samhuri's blog about programming, mainly about iOS and Ruby url = "https://samhuri.net" image_url = "/images/me.jpg" scripts = [] -styles = ["css/normalize.css", "css/style.css", "css/syntax.css"] +styles = ["/css/normalize.css", "/css/style.css", "/css/syntax.css"] plugins = ["posts", "projects"] [projects_plugin] scripts = [ "https://ajax.googleapis.com/ajax/libs/prototype/1.6.1.0/prototype.js", - "js/gitter.js", - "js/store.js", - "js/projects.js" + "/js/gitter.js", + "/js/store.js", + "/js/projects.js" ] styles = [] diff --git a/spec/config/loader_test.rb b/spec/config/loader_test.rb index ec60aa5..4a4df45 100644 --- a/spec/config/loader_test.rb +++ b/spec/config/loader_test.rb @@ -11,11 +11,11 @@ class Pressa::Config::LoaderTest < Minitest::Test assert_equal("Sami Samhuri", site.author) assert_equal("https://samhuri.net", site.url) assert_equal("https://samhuri.net/images/me.jpg", site.image_url) - assert_equal(["css/style.css"], site.styles.map(&:href)) + assert_equal(["/css/style.css"], site.styles.map(&:href)) projects_plugin = site.plugins.find { |plugin| plugin.is_a?(Pressa::Projects::Plugin) } refute_nil(projects_plugin) - assert_equal(["js/projects.js"], projects_plugin.scripts.map(&:src)) + assert_equal(["/js/projects.js"], projects_plugin.scripts.map(&:src)) end end @@ -338,6 +338,25 @@ class Pressa::Config::LoaderTest < Minitest::Test end end + def test_build_site_rejects_non_absolute_local_asset_paths + 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" + scripts = ["js/site.js"] + styles = ["css/site.css"] + TOML + File.write(File.join(dir, "projects.toml"), "") + + loader = Pressa::Config::Loader.new(source_path: dir) + error = assert_raises(Pressa::Config::ValidationError) { loader.build_site } + assert_match(%r{start with / or use http\(s\) scheme}, error.message) + end + end + private def with_temp_config @@ -350,11 +369,11 @@ class Pressa::Config::LoaderTest < Minitest::Test url = "https://samhuri.net" image_url = "/images/me.jpg" scripts = [] - styles = ["css/style.css"] + styles = ["/css/style.css"] plugins = ["posts", "projects"] [projects_plugin] - scripts = ["js/projects.js"] + scripts = ["/js/projects.js"] styles = [] TOML diff --git a/spec/posts/models_test.rb b/spec/posts/models_test.rb index fca5f6f..9acc302 100644 --- a/spec/posts/models_test.rb +++ b/spec/posts/models_test.rb @@ -70,7 +70,13 @@ class Pressa::Posts::ModelsTest < Minitest::Test assert_equal([11, 10], year_2025.sorted_months.map { |mp| mp.month.number }) assert_equal([regular_post, link_post], year_2025.all_posts) assert_equal([2025, 2024], posts_by_year.sorted_years) + assert_equal(2024, posts_by_year.earliest_year) assert_equal(3, posts_by_year.all_posts.length) assert_equal([regular_post], posts_by_year.recent_posts(1)) end + + def test_posts_by_year_earliest_year_is_nil_for_empty_collection + posts_by_year = Pressa::Posts::PostsByYear.new(by_year: {}) + assert_nil(posts_by_year.earliest_year) + end end diff --git a/spec/site_generator_rendering_test.rb b/spec/site_generator_rendering_test.rb index 6e14044..14af007 100644 --- a/spec/site_generator_rendering_test.rb +++ b/spec/site_generator_rendering_test.rb @@ -20,6 +20,20 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test end end + class PostsPluginSpy < PluginSpy + attr_reader :posts_by_year, :render_site_year + + def initialize(posts_by_year:) + super() + @posts_by_year = posts_by_year + end + + def render(site:, target_path:) + @render_site_year = site.copyright_start_year + super + end + end + class MarkdownRendererSpy attr_reader :calls @@ -51,6 +65,27 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test ) end + def build_posts_by_year(year:) + post = Pressa::Posts::Post.new( + slug: "first-post", + title: "First Post", + author: "Sami Samhuri", + date: DateTime.parse("#{year}-02-01T10:00:00-08:00"), + formatted_date: "1st February, #{year}", + body: "

First post

", + excerpt: "First post...", + path: "/posts/#{year}/02/first-post" + ) + + month_posts = Pressa::Posts::MonthPosts.new( + month: Pressa::Posts::Month.new(name: "February", number: 2, padded: "02"), + posts: [post] + ) + + year_posts = Pressa::Posts::YearPosts.new(year:, by_month: {2 => month_posts}) + Pressa::Posts::PostsByYear.new(by_year: {year => year_posts}) + end + def test_generate_runs_plugins_copies_static_files_and_renders_supported_files Dir.mktmpdir do |root| source_path = File.join(root, "source") @@ -110,4 +145,20 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test assert_empty(renderer.calls) end end + + def test_generate_sets_copyright_start_year_from_earliest_post_year + Dir.mktmpdir do |root| + source_path = File.join(root, "source") + target_path = File.join(root, "target") + FileUtils.mkdir_p(source_path) + + plugin = PostsPluginSpy.new(posts_by_year: build_posts_by_year(year: 2006)) + renderer = MarkdownRendererSpy.new + site = build_site(plugin:, renderer:) + + Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:) + + assert_equal(2006, plugin.render_site_year) + end + end end diff --git a/spec/views/layout_test.rb b/spec/views/layout_test.rb index 446a194..26cb724 100644 --- a/spec/views/layout_test.rb +++ b/spec/views/layout_test.rb @@ -21,6 +21,17 @@ class Pressa::Views::LayoutTest < Minitest::Test ) end + def site_with_copyright_start_year(year) + Pressa::Site.new( + author: "Sami Samhuri", + email: "sami@samhuri.net", + title: "samhuri.net", + description: "blog", + url: "https://samhuri.net", + copyright_start_year: year + ) + end + def test_rendering_child_components_as_html_instead_of_escaped_text html = Pressa::Views::Layout.new( site:, @@ -64,13 +75,25 @@ class Pressa::Views::LayoutTest < Minitest::Test assert_includes(html, %()) end - def test_format_output_is_enabled - layout = Pressa::Views::Layout.new( - site:, + def test_footer_renders_year_range_using_copyright_start_year + html = Pressa::Views::Layout.new( + site: site_with_copyright_start_year(2006), canonical_url: "https://samhuri.net/posts/", content: content_view - ) + ).call - assert(layout.format_output?) + assert_includes(html, "") + end + + def test_footer_renders_single_year_when_start_year_matches_current_year + current_year = Time.now.year + html = Pressa::Views::Layout.new( + site: site_with_copyright_start_year(current_year), + canonical_url: "https://samhuri.net/posts/", + content: content_view + ).call + + assert_includes(html, "") + refute_includes(html, "