main
rb 166 lines 6.13 KB
Raw
1 # frozen_string_literal: true
2
3 require "cgi"
4
5 # Renders the 1200×630 Open Graph "share card" PNGs that social platforms and
6 # crawlers show when a siGit URL is pasted into Slack / X / LinkedIn / Discord.
7 #
8 # Pipeline: build an SVG from the brand template, then rasterize it to PNG with
9 # libvips (installed in the production image). The caller is responsible for
10 # caching — generation is only ever invoked on a cache miss. Raises if libvips
11 # or its SVG/text support is unavailable so the controller can fall back to a
12 # static image.
13 class OgImageService
14 WIDTH = 1200
15 HEIGHT = 630
16
17 # Bump when the template changes so cached cards regenerate.
18 TEMPLATE_VERSION = "og-v1"
19
20 # Brand palette (mirrors config/tailwind.config.js).
21 BG_TOP = "#1a1d21" # surface-800
22 BG_BOTTOM = "#15171a" # surface-900
23 BRAND = "#FE6D36" # brand-500
24 TEXT = "#f3f4f6" # gray-100
25 MUTED = "#9ca3ae" # surface-300 / gray
26 BORDER = "#2d3238" # surface-600
27
28 # Fonts are substituted by fontconfig at rasterize time; the stack degrades to
29 # whatever sans-serif the host has installed.
30 FONT = "Inter, 'Helvetica Neue', Arial, sans-serif"
31
32 class << self
33 def render(repository:)
34 rasterize(repository_svg(repository))
35 end
36
37 def render_default
38 rasterize(default_svg)
39 end
40
41 private
42
43 # ── Rasterization ──────────────────────────────────────────────────────
44 def rasterize(svg)
45 require "vips"
46 image = Vips::Image.new_from_buffer(svg, "", access: :sequential)
47 image.write_to_buffer(".png")
48 rescue LoadError => e
49 raise Error, "libvips unavailable: #{e.message}"
50 rescue Vips::Error => e
51 raise Error, "rasterization failed: #{e.message}"
52 end
53
54 # ── Templates ──────────────────────────────────────────────────────────
55 def repository_svg(repository)
56 owner = "@#{repository.user.username}"
57 name = truncate(repository.name, 28)
58 desc_lines = wrap(repository.seo_description, width: 56, max_lines: 3)
59 lang = repository.primary_language
60 stars = repository.stars_count.to_i
61
62 <<~SVG
63 <svg xmlns="http://www.w3.org/2000/svg" width="#{WIDTH}" height="#{HEIGHT}" viewBox="0 0 #{WIDTH} #{HEIGHT}">
64 #{frame}
65 #{wordmark}
66 <text x="80" y="250" font-family="#{FONT}" font-size="30" fill="#{MUTED}" font-weight="500">#{esc(owner)} /</text>
67 <text x="80" y="330" font-family="#{FONT}" font-size="72" fill="#{TEXT}" font-weight="700">#{esc(name)}</text>
68 #{description_block(desc_lines, y: 400)}
69 #{footer(lang: lang, stars: stars)}
70 </svg>
71 SVG
72 end
73
74 def default_svg
75 <<~SVG
76 <svg xmlns="http://www.w3.org/2000/svg" width="#{WIDTH}" height="#{HEIGHT}" viewBox="0 0 #{WIDTH} #{HEIGHT}">
77 #{frame}
78 #{wordmark}
79 <text x="80" y="320" font-family="#{FONT}" font-size="68" fill="#{TEXT}" font-weight="700">Git hosting for the AI era</text>
80 #{description_block([ "Version repositories, publish open-weights models", "with full model cards, and hand the keys to your", "AI coding agent." ], y: 390)}
81 </svg>
82 SVG
83 end
84
85 # ── Reusable fragments ───────────────────────────────────────────────────
86 def frame
87 <<~SVG
88 <defs>
89 <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
90 <stop offset="0%" stop-color="#{BG_TOP}"/>
91 <stop offset="100%" stop-color="#{BG_BOTTOM}"/>
92 </linearGradient>
93 </defs>
94 <rect width="#{WIDTH}" height="#{HEIGHT}" fill="url(#bg)"/>
95 <rect width="#{WIDTH}" height="8" fill="#{BRAND}"/>
96 <rect x="20" y="20" width="#{WIDTH - 40}" height="#{HEIGHT - 40}" rx="20" fill="none" stroke="#{BORDER}" stroke-width="2"/>
97 SVG
98 end
99
100 # siGit wordmark, top-left.
101 def wordmark
102 <<~SVG
103 <text x="80" y="130" font-family="#{FONT}" font-size="40" font-weight="700">
104 <tspan fill="#{TEXT}">si</tspan><tspan fill="#{BRAND}">Git</tspan>
105 </text>
106 SVG
107 end
108
109 def description_block(lines, y:)
110 lines.each_with_index.map do |line, i|
111 %(<text x="80" y="#{y + (i * 46)}" font-family="#{FONT}" font-size="32" fill="#{MUTED}">#{esc(line)}</text>)
112 end.join("\n")
113 end
114
115 def footer(lang:, stars:)
116 parts = []
117 x = 80
118 if lang
119 name, color = lang
120 parts << %(<circle cx="#{x + 9}" cy="555" r="9" fill="#{esc(color)}"/>)
121 parts << %(<text x="#{x + 30}" y="564" font-family="#{FONT}" font-size="28" fill="#{TEXT}">#{esc(name)}</text>)
122 x += 50 + (name.length * 15)
123 end
124 if stars.positive?
125 parts << %(<text x="#{x}" y="564" font-family="#{FONT}" font-size="28" fill="#{MUTED}">★ #{stars}</text>)
126 end
127 parts.join("\n")
128 end
129
130 # ── Text helpers ─────────────────────────────────────────────────────────
131 def esc(text)
132 CGI.escapeHTML(text.to_s)
133 end
134
135 def truncate(text, length)
136 text = text.to_s
137 text.length > length ? "#{text[0, length - 1]}…" : text
138 end
139
140 # Greedy word wrap to at most +max_lines+ lines of ~+width+ chars, with an
141 # ellipsis on the last line if the text overflows.
142 def wrap(text, width:, max_lines:)
143 words = text.to_s.split(/\s+/)
144 lines = []
145 current = +""
146 words.each do |word|
147 candidate = current.empty? ? word : "#{current} #{word}"
148 if candidate.length <= width || current.empty?
149 current = candidate
150 else
151 lines << current
152 current = word
153 end
154 end
155 lines << current unless current.empty?
156
157 if lines.length > max_lines
158 lines = lines[0, max_lines]
159 lines[-1] = "#{lines[-1]}…"
160 end
161 lines
162 end
163 end
164
165 class Error < StandardError; end
166 end