Derive site metadata from posts and config

This commit is contained in:
Sami Samhuri 2026-02-07 21:05:59 -08:00
parent 54a0543a7f
commit ca316bf470
No known key found for this signature in database
10 changed files with 169 additions and 21 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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 = []

View file

@ -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

View file

@ -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

View file

@ -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: "<p>First post</p>",
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

View file

@ -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, %(<link rel="stylesheet" type="text/css" href="https://cdn.example.com/site.css">))
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, "<footer>© 2006 - #{Time.now.year} <a href=\"https://samhuri.net/about\">Sami Samhuri</a></footer>")
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, "<footer>© #{current_year} <a href=\"https://samhuri.net/about\">Sami Samhuri</a></footer>")
refute_includes(html, "<footer>© #{current_year} - #{current_year} ")
end
end