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