Skip to content

Commit 5b52fc3

Browse files
committed
feat: dynamic OG image generation from achievements YAML
Replace static og-image.jpg with dynamically generated PNG that auto-updates wins count and earnings from achievements.yml. Uses MiniMagick + JetBrains Mono font. Cached until YAML changes. - Add OgImageGenerator service - Add /og-image endpoint with HTTP caching - Bundle JetBrains Mono fonts for Docker - Add imagemagick + fontconfig to Dockerfile
1 parent ee2149e commit 5b52fc3

File tree

7 files changed

+121
-3
lines changed

7 files changed

+121
-3
lines changed

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ WORKDIR /rails
1616

1717
# Install base packages
1818
RUN apt-get update -qq && \
19-
apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \
19+
apt-get install --no-install-recommends -y curl libjemalloc2 libvips imagemagick fontconfig postgresql-client && \
2020
ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
2121
rm -rf /var/lib/apt/lists /var/cache/apt/archives
2222

271 KB
Binary file not shown.
267 KB
Binary file not shown.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
class OgImagesController < ApplicationController
4+
def show
5+
path = OgImageGenerator.cached_path
6+
expires_in 1.hour, public: true
7+
send_file path, type: "image/png", disposition: "inline"
8+
end
9+
end

app/services/og_image_generator.rb

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# frozen_string_literal: true
2+
3+
require "mini_magick"
4+
5+
# Generates dynamic Open Graph images with live achievement stats.
6+
# Cached in tmp/cache/og/ — regenerates when achievements.yml changes.
7+
class OgImageGenerator
8+
WIDTH = 1200
9+
HEIGHT = 630
10+
BG_COLOR = "#2D2320"
11+
12+
def self.cached_path
13+
new.cached_path
14+
end
15+
16+
def cached_path
17+
generate! unless valid_cache?
18+
cache_file
19+
end
20+
21+
private
22+
23+
def cache_file
24+
Rails.root.join("tmp", "cache", "og", "og-image.png")
25+
end
26+
27+
def valid_cache?
28+
cache_file.exist? && cache_file.mtime > achievements_mtime
29+
end
30+
31+
def achievements_mtime
32+
Rails.root.join("config", "achievements.yml").mtime
33+
end
34+
35+
def generate!
36+
FileUtils.mkdir_p(cache_file.dirname)
37+
38+
# Build canvas with text layers
39+
MiniMagick::Tool::Convert.new do |c|
40+
c.size "#{WIDTH}x#{HEIGHT}"
41+
c.xc BG_COLOR
42+
43+
# Accent line (orange)
44+
c.fill "#E58C2E"
45+
c.draw "rectangle 80,225 130,229"
46+
47+
# "RECTOR" label
48+
c.fill "white"
49+
c.font font_path(:bold)
50+
c.pointsize 22
51+
c.gravity "NorthWest"
52+
c.annotate "+120+68", "RECTOR"
53+
54+
# "Building for Eternity" heading
55+
c.pointsize 48
56+
c.annotate "+80+260", "Building for Eternity"
57+
58+
# Dynamic stats line
59+
c.fill "#B0A090"
60+
c.font font_path(:regular)
61+
c.pointsize 22
62+
c.annotate "+80+345", stats_text
63+
64+
# URL bottom-right
65+
c.pointsize 16
66+
c.gravity "SouthEast"
67+
c.annotate "+50+40", "rectorspace.com"
68+
69+
c << cache_file.to_s
70+
end
71+
72+
# Composite profile picture
73+
composite_profile!
74+
end
75+
76+
def composite_profile!
77+
profile = MiniMagick::Image.open(profile_path.to_s)
78+
profile.resize "60x60"
79+
80+
canvas = MiniMagick::Image.open(cache_file.to_s)
81+
result = canvas.composite(profile) do |comp|
82+
comp.compose "Over"
83+
comp.geometry "+50+55"
84+
end
85+
result.write(cache_file.to_s)
86+
end
87+
88+
def stats_text
89+
count = Achievement.win_count
90+
earnings = ActiveSupport::NumberHelper.number_to_delimited(Achievement.total_earnings)
91+
"Full-stack builder. Hackathon hunter. #{count} wins, ~$#{earnings} earned."
92+
end
93+
94+
def profile_path
95+
Rails.root.join("app", "assets", "images", "rector_profile_image.png")
96+
end
97+
98+
def font_path(weight)
99+
name = weight == :bold ? "JetBrainsMono-Bold" : "JetBrainsMono-Regular"
100+
bundled = Rails.root.join("app", "assets", "fonts", "#{name}.ttf")
101+
return bundled.to_s if bundled.exist?
102+
103+
# Fallback to system font
104+
weight == :bold ? "DejaVu-Sans-Mono-Bold" : "DejaVu-Sans-Mono"
105+
end
106+
end

app/views/layouts/application.html.erb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<meta property="og:url" content="https://rectorspace.com<%= request.path %>">
1919
<meta property="og:title" content="<%= content_for(:title) ? "#{content_for(:title)} • RECTOR" : "RECTOR • Building for Eternity" %>">
2020
<meta property="og:description" content="<%= content_for(:meta_description) || "Full-stack builder. Hackathon hunter. #{achievements_summary}. Building for eternity." %>">
21-
<meta property="og:image" content="<%= content_for(:og_image) || "https://rectorspace.com#{asset_path('og-image.jpg')}" %>">
21+
<meta property="og:image" content="<%= content_for(:og_image) || "https://rectorspace.com/og-image" %>">
2222
<meta property="og:site_name" content="RECTOR">
2323

2424
<%# Twitter Card meta tags %>
@@ -27,7 +27,7 @@
2727
<meta name="twitter:creator" content="@RZ1989sol">
2828
<meta name="twitter:title" content="<%= content_for(:title) ? "#{content_for(:title)} • RECTOR" : "RECTOR • Building for Eternity" %>">
2929
<meta name="twitter:description" content="<%= content_for(:meta_description) || "Full-stack builder. Hackathon hunter. #{achievements_summary}. Building for eternity." %>">
30-
<meta name="twitter:image" content="<%= content_for(:og_image) || "https://rectorspace.com#{asset_path('og-image.jpg')}" %>">
30+
<meta name="twitter:image" content="<%= content_for(:og_image) || "https://rectorspace.com/og-image" %>">
3131

3232
<%= yield :head %>
3333

config/routes.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
get "apply/arbital/retro", to: "apply#arbital_retro", as: :apply_arbital_retro
1414
get "apply/arbital/modern", to: "apply#arbital_modern", as: :apply_arbital_modern
1515

16+
# Dynamic OG image (auto-updates from achievements.yml)
17+
get "og-image", to: "og_images#show", as: :og_image
18+
1619
# Health check
1720
get "up" => "rails/health#show", as: :rails_health_check
1821

0 commit comments

Comments
 (0)