Add per-post Image frontmatter and richer OG/article meta tags

Pass an optional Image field through PostMetadata -> Post -> PostWriter
so individual posts (especially link posts) can set their own og:image
instead of always falling back to the site-wide image. Also emit
article:published_time and article:tag for article pages, and switch
twitter:card to summary_large_image when a post-specific image is set.
This commit is contained in:
Sami Samhuri 2026-06-21 23:30:41 -07:00
parent 24bc321f2b
commit 760c13b0b6
8 changed files with 122 additions and 5 deletions

View file

@ -6,7 +6,7 @@ module Pressa
class PostMetadata
REQUIRED_FIELDS = %w[Title Author Date Timestamp].freeze
attr_reader :title, :author, :date, :formatted_date, :link, :tags
attr_reader :title, :author, :date, :formatted_date, :link, :tags, :image
def initialize(yaml_hash)
@raw = yaml_hash
@ -39,6 +39,7 @@ module Pressa
@formatted_date = @raw["Date"]
@link = @raw["Link"]
@tags = parse_tags(@raw["Tags"])
@image = @raw["Image"]
end
def parse_tags(value)

View file

@ -11,6 +11,7 @@ module Pressa
attribute :formatted_date, Types::String
attribute :link, Types::String.optional.default(nil)
attribute :tags, Types::Array.of(Types::String).default([].freeze)
attribute :image, Types::String.optional.default(nil)
attribute :body, Types::String
attribute :markdown_body, Types::String.default("".freeze)
attribute :excerpt, Types::String

View file

@ -47,6 +47,7 @@ module Pressa
formatted_date: metadata.formatted_date,
link: metadata.link,
tags: metadata.tags,
image: metadata.image,
body: html_body,
markdown_body: body_markdown,
excerpt:,

View file

@ -117,7 +117,10 @@ module Pressa
canonical_url: @site.url_for(post.path),
content: content_view,
page_description: post.excerpt,
page_type: "article"
page_type: "article",
page_image: post.image,
page_tags: post.tags,
page_published_time: post.date.iso8601
)
file_path = File.join(target_path, post.path.sub(/^\//, ""), "index.html")
@ -161,7 +164,10 @@ module Pressa
canonical_url:,
content:,
page_description: nil,
page_type: "website"
page_type: "website",
page_image: nil,
page_tags: [],
page_published_time: nil
)
layout = Views::Layout.new(
site: @site,
@ -169,6 +175,9 @@ module Pressa
canonical_url:,
page_description:,
page_type:,
page_image:,
page_tags:,
page_published_time:,
content:
)

View file

@ -8,6 +8,9 @@ module Pressa
:page_subtitle,
:page_description,
:page_type,
:page_image,
:page_tags,
:page_published_time,
:canonical_url,
:page_scripts,
:page_styles,
@ -18,6 +21,9 @@ module Pressa
canonical_url:, page_subtitle: nil,
page_description: nil,
page_type: "website",
page_image: nil,
page_tags: [],
page_published_time: nil,
page_scripts: [],
page_styles: [],
content: nil
@ -26,6 +32,9 @@ module Pressa
@page_subtitle = page_subtitle
@page_description = page_description
@page_type = page_type
@page_image = page_image
@page_tags = page_tags
@page_published_time = page_published_time
@canonical_url = canonical_url
@page_scripts = page_scripts
@page_styles = page_styles
@ -54,7 +63,12 @@ module Pressa
meta(property: "og:image", content: og_image_url) if og_image_url
meta(property: "og:type", content: page_type)
meta(property: "article:author", content: site.author)
meta(name: "twitter:card", content: "summary")
meta(name: "twitter:card", content: twitter_card_type)
if page_type == "article"
meta(property: "article:published_time", content: page_published_time) if page_published_time
page_tags.each { |tag| meta(property: "article:tag", content: tag) }
end
link(
rel: "alternate",
@ -108,9 +122,18 @@ module Pressa
end
def og_image_url
if page_image
return page_image if page_image.start_with?("http://", "https://")
return absolute_asset(page_image)
end
site.image_url
end
def twitter_card_type
page_image ? "summary_large_image" : "summary"
end
def all_styles
site.styles + page_styles
end

View file

@ -10,6 +10,7 @@ class Pressa::Posts::PostMetadataTest < Minitest::Test
Timestamp: 2025-11-05T10:00:00-08:00
Tags: Ruby, Testing
Link: https://example.net/external
Image: /images/blog/test-post.png
---
This is the post body.
@ -25,6 +26,7 @@ class Pressa::Posts::PostMetadataTest < Minitest::Test
assert_equal(5, metadata.date.day)
assert_equal("https://example.net/external", metadata.link)
assert_equal(["Ruby", "Testing"], metadata.tags)
assert_equal("/images/blog/test-post.png", metadata.image)
end
def test_parse_raises_error_when_required_fields_are_missing
@ -56,6 +58,7 @@ class Pressa::Posts::PostMetadataTest < Minitest::Test
assert_equal([], metadata.tags)
assert_nil(metadata.link)
assert_nil(metadata.image)
end
def test_parse_raises_error_when_front_matter_is_missing

View file

@ -23,7 +23,8 @@ class Pressa::Posts::PostWriterTest < Minitest::Test
link: "https://example.net/linked",
body: "<p>linked body</p>",
excerpt: "linked body...",
path: "/posts/2025/11/link-post"
path: "/posts/2025/11/link-post",
image: "/images/blog/link-post.png"
)
regular_post = Pressa::Posts::Post.new(
slug: "regular-post",
@ -73,6 +74,22 @@ class Pressa::Posts::PostWriterTest < Minitest::Test
end
end
def test_write_posts_uses_post_image_for_og_image_and_article_tags
Dir.mktmpdir do |dir|
writer.write_posts(target_path: dir)
linked = File.join(dir, "posts/2025/11/link-post/index.html")
html = File.read(linked)
assert_includes(html, %(<meta property="og:image" content="https://samhuri.net/images/blog/link-post.png">))
assert_includes(html, %(<meta property="article:published_time" content="2025-11-05T10:00:00-08:00">))
regular = File.join(dir, "posts/2024/10/regular-post/index.html")
regular_html = File.read(regular)
assert_includes(regular_html, %(<meta property="article:tag" content="ruby">))
end
end
def test_write_recent_posts_writes_index_page
Dir.mktmpdir do |dir|
writer.write_recent_posts(target_path: dir, limit: 1)

View file

@ -140,4 +140,66 @@ class Pressa::Views::LayoutTest < Minitest::Test
refute_includes(html, "techhub.social")
refute_includes(html, "github.com/samsonjs")
end
def test_page_image_overrides_og_image_and_uses_large_image_card
html = Pressa::Views::Layout.new(
site:,
canonical_url: "https://samhuri.net/posts/",
page_image: "/images/blog/post.png",
content: content_view
).call
assert_includes(html, %(<meta property="og:image" content="https://samhuri.net/images/blog/post.png">))
assert_includes(html, %(<meta name="twitter:card" content="summary_large_image">))
end
def test_page_image_preserves_absolute_urls
html = Pressa::Views::Layout.new(
site:,
canonical_url: "https://samhuri.net/posts/",
page_image: "https://cdn.example.net/preview.png",
content: content_view
).call
assert_includes(html, %(<meta property="og:image" content="https://cdn.example.net/preview.png">))
end
def test_default_twitter_card_is_summary_without_page_image
html = Pressa::Views::Layout.new(
site:,
canonical_url: "https://samhuri.net/posts/",
content: content_view
).call
assert_includes(html, %(<meta name="twitter:card" content="summary">))
end
def test_article_tags_render_published_time_and_tags
html = Pressa::Views::Layout.new(
site:,
canonical_url: "https://samhuri.net/posts/2025/11/05/post/",
page_type: "article",
page_published_time: "2025-11-05T10:00:00-08:00",
page_tags: ["ruby", "rails"],
content: content_view
).call
assert_includes(html, %(<meta property="article:published_time" content="2025-11-05T10:00:00-08:00">))
assert_includes(html, %(<meta property="article:tag" content="ruby">))
assert_includes(html, %(<meta property="article:tag" content="rails">))
end
def test_article_tags_do_not_render_for_non_article_pages
html = Pressa::Views::Layout.new(
site:,
canonical_url: "https://samhuri.net/posts/",
page_type: "website",
page_published_time: "2025-11-05T10:00:00-08:00",
page_tags: ["ruby"],
content: content_view
).call
refute_includes(html, "article:published_time")
refute_includes(html, "article:tag")
end
end