Skip to content

Commit 80f05dc

Browse files
jhashclaude
andcommitted
Add blog feature with SEO, i18n, and tagging support
- Create blog posts table with comprehensive SEO fields (meta tags, Open Graph, Twitter cards) - Implement polymorphic tagging system for flexible content categorization - Add BlogPost, Tag, and Tagging models with proper associations - Create blog posts controller with index and show actions - Design NYT-style blog views with responsive layout - Add RSS and Atom feed support for blog posts - Create rake tasks for generating and deleting example blog posts - Add internationalization support for all blog text - Include Blog link in main navigation - Simple pagination implementation without external dependencies 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5c4b0f6 commit 80f05dc

17 files changed

Lines changed: 721 additions & 1 deletion
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
class BlogPostsController < ApplicationController
2+
before_action :set_blog_post, only: [:show]
3+
4+
def index
5+
@blog_posts = BlogPost.published.recent.includes(:user, :tags)
6+
7+
# Simple pagination without kaminari
8+
page = (params[:page] || 1).to_i
9+
per_page = 9
10+
@blog_posts = @blog_posts.limit(per_page).offset((page - 1) * per_page)
11+
12+
respond_to do |format|
13+
format.html
14+
format.rss { render layout: false }
15+
format.atom { render layout: false }
16+
end
17+
end
18+
19+
def show
20+
unless @blog_post.published? || (logged_in? && (current_user == @blog_post.user || current_user.superadmin?))
21+
redirect_to blog_posts_path, alert: t('blog_posts.not_found')
22+
return
23+
end
24+
25+
@blog_post.increment_views! if @blog_post.published?
26+
end
27+
28+
private
29+
30+
def set_blog_post
31+
@blog_post = BlogPost.includes(:user, :tags).find_by!(slug: params[:id])
32+
end
33+
end

app/models/blog_post.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
class BlogPost < ApplicationRecord
2+
belongs_to :user
3+
has_many :taggings, as: :taggable, dependent: :destroy
4+
has_many :tags, through: :taggings
5+
6+
# Status enum
7+
enum :status, { draft: 0, published: 1, archived: 2 }
8+
9+
# Validations
10+
validates :title, presence: true
11+
validates :slug, presence: true, uniqueness: true
12+
validates :content, presence: true
13+
14+
# Scopes
15+
scope :published, -> { where(status: :published).where('published_at <= ?', Time.current) }
16+
scope :recent, -> { order(published_at: :desc) }
17+
scope :featured, -> { where.not(featured_image_url: nil) }
18+
19+
# Callbacks
20+
before_validation :generate_slug, if: -> { slug.blank? && title.present? }
21+
before_validation :set_published_at, if: -> { status_changed? && published? }
22+
before_save :calculate_reading_time
23+
before_save :set_seo_defaults
24+
25+
# Instance methods
26+
def to_param
27+
slug
28+
end
29+
30+
def published?
31+
status == 'published' && published_at.present? && published_at <= Time.current
32+
end
33+
34+
def increment_views!
35+
increment!(:views_count)
36+
end
37+
38+
def tag_list
39+
tags.pluck(:name).join(', ')
40+
end
41+
42+
def tag_list=(names)
43+
self.tags = names.split(',').map do |name|
44+
Tag.where(name: name.strip).first_or_create!
45+
end
46+
end
47+
48+
private
49+
50+
def generate_slug
51+
self.slug = title.parameterize
52+
# Ensure uniqueness
53+
if BlogPost.where.not(id: id).exists?(slug: slug)
54+
self.slug = "#{slug}-#{SecureRandom.hex(4)}"
55+
end
56+
end
57+
58+
def set_published_at
59+
self.published_at ||= Time.current if published?
60+
end
61+
62+
def calculate_reading_time
63+
return unless content.present?
64+
word_count = content.split.size
65+
self.reading_time_minutes = (word_count / 200.0).ceil # Average reading speed: 200 words/minute
66+
end
67+
68+
def set_seo_defaults
69+
self.meta_title ||= title
70+
self.meta_description ||= excerpt || content.truncate(160)
71+
self.og_title ||= meta_title
72+
self.og_description ||= meta_description
73+
self.twitter_title ||= meta_title
74+
self.twitter_description ||= meta_description
75+
end
76+
end

app/models/tag.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
class Tag < ApplicationRecord
2+
has_many :taggings, dependent: :destroy
3+
has_many :blog_posts, through: :taggings, source: :taggable, source_type: 'BlogPost'
4+
5+
# Validations
6+
validates :name, presence: true, uniqueness: true
7+
validates :slug, presence: true, uniqueness: true
8+
9+
# Callbacks
10+
before_validation :generate_slug, if: -> { slug.blank? && name.present? }
11+
12+
# Scopes
13+
scope :popular, -> { joins(:taggings).group(:id).order('COUNT(taggings.id) DESC') }
14+
15+
def to_param
16+
slug
17+
end
18+
19+
private
20+
21+
def generate_slug
22+
self.slug = name.parameterize
23+
end
24+
end

app/models/tagging.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class Tagging < ApplicationRecord
2+
belongs_to :tag
3+
belongs_to :taggable, polymorphic: true
4+
5+
# Validations
6+
validates :tag_id, uniqueness: { scope: [:taggable_type, :taggable_id] }
7+
end

app/models/user.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,7 @@ def make_superadmin!
4545
def remove_superadmin!
4646
roles.delete(Role.superadmin) if superadmin?
4747
end
48+
49+
# Associations
50+
has_many :blog_posts, dependent: :destroy
4851
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
atom_feed do |feed|
2+
feed.title t('blog_posts.atom.title', default: 'Blog - Eight')
3+
feed.updated @blog_posts.maximum(:updated_at) || Time.current
4+
5+
@blog_posts.each do |blog_post|
6+
feed.entry blog_post, published: blog_post.published_at do |entry|
7+
entry.title blog_post.title
8+
entry.content blog_post.content, type: 'html'
9+
10+
entry.author do |author|
11+
author.name blog_post.user.name
12+
author.email blog_post.user.email
13+
end
14+
15+
blog_post.tags.each do |tag|
16+
entry.category term: tag.slug, label: tag.name
17+
end
18+
end
19+
end
20+
end
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<div class="container mx-auto px-4 py-8 max-w-7xl">
2+
<!-- NYT-style header -->
3+
<header class="border-b-4 border-black mb-8">
4+
<h1 class="text-5xl font-serif font-bold mb-4"><%= t('blog_posts.index.title', default: 'The Blog') %></h1>
5+
<p class="text-gray-600 mb-4"><%= t('blog_posts.index.subtitle', default: 'Latest insights and updates') %></p>
6+
</header>
7+
8+
<!-- Blog posts grid -->
9+
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
10+
<% @blog_posts.each_with_index do |blog_post, index| %>
11+
<article class="<%= 'lg:col-span-2 lg:row-span-2' if index == 0 %> border-b pb-8 mb-8">
12+
<%= link_to blog_post_path(blog_post), class: "group block" do %>
13+
<% if blog_post.featured_image_url.present? %>
14+
<div class="mb-4 overflow-hidden <%= index == 0 ? 'h-96' : 'h-48' %>">
15+
<%= image_tag blog_post.featured_image_url,
16+
alt: blog_post.featured_image_alt || blog_post.title,
17+
class: "w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" %>
18+
</div>
19+
<% end %>
20+
21+
<div class="space-y-2">
22+
<% if blog_post.tags.any? %>
23+
<div class="flex gap-2 flex-wrap">
24+
<% blog_post.tags.limit(3).each do |tag| %>
25+
<span class="text-xs font-semibold uppercase tracking-wider text-red-600">
26+
<%= tag.name %>
27+
</span>
28+
<% end %>
29+
</div>
30+
<% end %>
31+
32+
<h2 class="text-<%= index == 0 ? '3xl' : '2xl' %> font-serif font-bold leading-tight group-hover:underline">
33+
<%= blog_post.title %>
34+
</h2>
35+
36+
<% if blog_post.excerpt.present? %>
37+
<p class="text-gray-700 <%= index == 0 ? 'text-lg' : '' %> line-clamp-3">
38+
<%= blog_post.excerpt %>
39+
</p>
40+
<% end %>
41+
42+
<div class="flex items-center text-sm text-gray-600 pt-2">
43+
<span class="font-semibold"><%= blog_post.user.name %></span>
44+
<span class="mx-2">·</span>
45+
<time datetime="<%= blog_post.published_at.iso8601 %>">
46+
<%= l(blog_post.published_at, format: :long) %>
47+
</time>
48+
<% if blog_post.reading_time_minutes.present? %>
49+
<span class="mx-2">·</span>
50+
<span><%= t('blog_posts.reading_time', count: blog_post.reading_time_minutes, default: '%{count} min read') %></span>
51+
<% end %>
52+
</div>
53+
</div>
54+
<% end %>
55+
</article>
56+
<% end %>
57+
</div>
58+
59+
<!-- Simple pagination -->
60+
<% if @blog_posts.size == 9 || params[:page].to_i > 1 %>
61+
<nav class="mt-12 flex justify-center space-x-2">
62+
<% if params[:page].to_i > 1 %>
63+
<%= link_to "← Previous", blog_posts_path(page: params[:page].to_i - 1),
64+
class: "px-4 py-2 border border-gray-300 rounded hover:bg-gray-100" %>
65+
<% end %>
66+
<% if @blog_posts.size == 9 %>
67+
<%= link_to "Next →", blog_posts_path(page: (params[:page] || 1).to_i + 1),
68+
class: "px-4 py-2 border border-gray-300 rounded hover:bg-gray-100" %>
69+
<% end %>
70+
</nav>
71+
<% end %>
72+
</div>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
xml.instruct! :xml, version: "1.0"
2+
xml.rss version: "2.0" do
3+
xml.channel do
4+
xml.title t('blog_posts.rss.title', default: 'Blog - Eight')
5+
xml.description t('blog_posts.rss.description', default: 'Latest blog posts from Eight')
6+
xml.link blog_posts_url
7+
xml.language I18n.locale.to_s
8+
9+
@blog_posts.each do |blog_post|
10+
xml.item do
11+
xml.title blog_post.title
12+
xml.description blog_post.excerpt || blog_post.content.to_s.gsub(/<[^>]*>/, '').truncate(300)
13+
xml.pubDate blog_post.published_at.to_s(:rfc822)
14+
xml.link blog_post_url(blog_post)
15+
xml.guid blog_post_url(blog_post)
16+
17+
blog_post.tags.each do |tag|
18+
xml.category tag.name
19+
end
20+
21+
xml.author "#{blog_post.user.email} (#{blog_post.user.name})"
22+
end
23+
end
24+
end
25+
end

app/views/blog_posts/show.html.erb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<article class="container mx-auto px-4 py-8 max-w-4xl">
2+
<!-- Article header -->
3+
<header class="mb-8">
4+
<% if @blog_post.tags.any? %>
5+
<div class="flex gap-2 flex-wrap mb-4">
6+
<% @blog_post.tags.each do |tag| %>
7+
<span class="text-xs font-semibold uppercase tracking-wider text-red-600">
8+
<%= tag.name %>
9+
</span>
10+
<% end %>
11+
</div>
12+
<% end %>
13+
14+
<h1 class="text-4xl md:text-5xl font-serif font-bold leading-tight mb-4">
15+
<%= @blog_post.title %>
16+
</h1>
17+
18+
<% if @blog_post.excerpt.present? %>
19+
<p class="text-xl text-gray-700 mb-6 font-serif italic">
20+
<%= @blog_post.excerpt %>
21+
</p>
22+
<% end %>
23+
24+
<div class="flex items-center text-gray-600 border-t border-b border-gray-300 py-4">
25+
<div class="flex-1">
26+
<div class="font-semibold text-black"><%= @blog_post.user.name %></div>
27+
<time datetime="<%= @blog_post.published_at.iso8601 %>" class="text-sm">
28+
<%= l(@blog_post.published_at, format: :long) %>
29+
</time>
30+
</div>
31+
<% if @blog_post.reading_time_minutes.present? %>
32+
<div class="text-sm">
33+
<%= t('blog_posts.reading_time', count: @blog_post.reading_time_minutes, default: '%{count} min read') %>
34+
</div>
35+
<% end %>
36+
</div>
37+
</header>
38+
39+
<!-- Featured image -->
40+
<% if @blog_post.featured_image_url.present? %>
41+
<figure class="mb-8">
42+
<%= image_tag @blog_post.featured_image_url,
43+
alt: @blog_post.featured_image_alt || @blog_post.title,
44+
class: "w-full rounded-lg shadow-lg" %>
45+
<% if @blog_post.featured_image_alt.present? %>
46+
<figcaption class="text-sm text-gray-600 mt-2 text-center italic">
47+
<%= @blog_post.featured_image_alt %>
48+
</figcaption>
49+
<% end %>
50+
</figure>
51+
<% end %>
52+
53+
<!-- Article content -->
54+
<div class="prose prose-lg max-w-none font-serif">
55+
<%= simple_format(@blog_post.content) %>
56+
</div>
57+
58+
<!-- Article footer -->
59+
<footer class="mt-12 pt-8 border-t border-gray-300">
60+
<div class="flex items-center justify-between">
61+
<div class="text-sm text-gray-600">
62+
<%= t('blog_posts.views', count: @blog_post.views_count, default: '%{count} views') %>
63+
</div>
64+
65+
<% if logged_in? && (current_user == @blog_post.user || current_user.superadmin?) %>
66+
<div class="flex gap-4">
67+
<%= link_to t('blog_posts.edit'), "#",
68+
class: "text-blue-600 hover:underline" %>
69+
</div>
70+
<% end %>
71+
</div>
72+
73+
<!-- Back to blog link -->
74+
<div class="mt-8">
75+
<%= link_to blog_posts_path, class: "inline-flex items-center text-blue-600 hover:underline" do %>
76+
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
77+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
78+
</svg>
79+
<%= t('blog_posts.back_to_blog', default: 'Back to Blog') %>
80+
<% end %>
81+
</div>
82+
</footer>
83+
</article>
84+
85+
<!-- Structured data for SEO -->
86+
<% if @blog_post.structured_data.present? %>
87+
<script type="application/ld+json">
88+
<%= @blog_post.structured_data.to_json.html_safe %>
89+
</script>
90+
<% else %>
91+
<script type="application/ld+json">
92+
{
93+
"@context": "https://schema.org",
94+
"@type": "BlogPosting",
95+
"headline": <%= @blog_post.title.to_json.html_safe %>,
96+
"description": <%= (@blog_post.excerpt || @blog_post.meta_description).to_json.html_safe %>,
97+
"author": {
98+
"@type": "Person",
99+
"name": <%= @blog_post.user.name.to_json.html_safe %>
100+
},
101+
"datePublished": <%= @blog_post.published_at.iso8601.to_json.html_safe %>,
102+
"dateModified": <%= @blog_post.updated_at.iso8601.to_json.html_safe %>,
103+
<% if @blog_post.featured_image_url.present? %>
104+
"image": <%= @blog_post.featured_image_url.to_json.html_safe %>,
105+
<% end %>
106+
"publisher": {
107+
"@type": "Organization",
108+
"name": <%= Rails.application.config.application_name.to_json.html_safe rescue "Eight".to_json.html_safe %>
109+
},
110+
"mainEntityOfPage": {
111+
"@type": "WebPage",
112+
"@id": <%= blog_post_url(@blog_post).to_json.html_safe %>
113+
}
114+
}
115+
</script>
116+
<% end %>

0 commit comments

Comments
 (0)