feat(seo): social unfurls + OG share cards for public repos

Make every shared public-repo link a preview card and a backlink: - Per-repo Open Graph / Twitter / canonical / SoftwareSourceCode JSON-LD, server-rendered in the initial HTML (crawlers don't run JS). Driven by a shared _repository_social partial + SeoHelper, with sane fallbacks when a repo has no description. - Auto-generated 1200x630 OG cards: GET /og/:owner/:repo.png and /og.png, rendered by OgImageService (SVG -> PNG via libvips), cached by content hash with ETag + long public max-age, with a static fallback so the endpoint never 500s. Dockerfile gains librsvg2-2 + fonts-dejavu-core. - Privacy guards: private repos 404 on the OG endpoint (no card, even for the owner) and stay out of the sitemap; auth/settings/billing/new pages emit noindex via a controller noindex! helper. - robots.txt disallows /new; og:image width/height/type + twitter:image:alt added to the shared SEO partial. - Tests for meta output (public vs private), OG endpoint (PNG + cache headers + 304 + private 404 + dotted names), sitemap/robots, and OG SVG escaping. - Docs: docs/seo-and-social-unfurls.md (what's emitted + how to verify). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 19:21 UTC 7fa3462344b242b9bb380858d2d573a5f084aca9
32 files changed +765 -9
Dockerfile
+3 -2
@@ -14,9 +14,10 @@ FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
14 # Rails app lives here
15 WORKDIR /rails
16
17 -# Install base packages
17 +# Install base packages. librsvg2-2 + a base font give libvips SVG-with-text
18 +# rasterization for the Open Graph share cards (OgImageService).
19 RUN apt-get update -qq && \
19 - apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \
20 + apt-get install --no-install-recommends -y curl libjemalloc2 libvips librsvg2-2 fonts-dejavu-core postgresql-client && \
21 ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \
22 rm -rf /var/lib/apt/lists /var/cache/apt/archives
23
app/controllers/application_controller.rb
+7
@@ -25,4 +25,11 @@ class ApplicationController < ActionController::Base
25 redirect_to signin_path, alert: "Please sign in to continue."
26 end
27 end
28 +
29 + # Marks the current response as off-limits to search engines. The SEO partial
30 + # reads @noindex and emits <meta name="robots" content="noindex, nofollow">.
31 + # Use for auth, settings, billing, and other non-content/transactional pages.
32 + def noindex!
33 + @noindex = true
34 + end
35 end
app/controllers/billing_controller.rb
+1
@@ -5,6 +5,7 @@
5 # the web so the desktop app can link here and avoid the App Store cut.
6 class BillingController < ApplicationController
7 before_action :require_sign_in!
8 + before_action :noindex!
9
10 def show
11 @subscription = current_user.subscription
app/controllers/confirmations_controller.rb
+1
@@ -1,6 +1,7 @@
1 # frozen_string_literal: true
2
3 class ConfirmationsController < ApplicationController
4 + before_action :noindex!
5 before_action :redirect_if_signed_in
6
7 # GET /auth/confirmation/resend
app/controllers/og_images_controller.rb new
+57
@@ -0,0 +1,57 @@
1 +# frozen_string_literal: true
2 +
3 +# Serves the Open Graph share-card PNGs referenced by the OG/Twitter meta tags.
4 +#
5 +# Cards are cached by content hash (OgImageService never runs on the hot path
6 +# when a cached card exists) and answered with a long-lived, public, ETag'd
7 +# response so crawlers and CDNs only fetch bytes once. Private repositories are
8 +# never rendered — the endpoint 404s so no preview can leak.
9 +class OgImagesController < ApplicationController
10 + CACHE_TTL = 7.days
11 + FALLBACK_IMAGE = "favicon/web-app-manifest-512x512.png"
12 + FALLBACK_CACHE_TTL = 5.minutes
13 +
14 + # GET /og/:username/:repository.png
15 + def show
16 + repository = public_repository
17 + return head :not_found unless repository
18 +
19 + serve_card("#{OgImageService::TEMPLATE_VERSION}-#{repository.og_version}") do
20 + OgImageService.render(repository: repository)
21 + end
22 + end
23 +
24 + # GET /og.png — the generic sitewide card (default og:image).
25 + def default
26 + serve_card("#{OgImageService::TEMPLATE_VERSION}-default") do
27 + OgImageService.render_default
28 + end
29 + end
30 +
31 + private
32 +
33 + def public_repository
34 + owner = User.find_by(username: params[:username])
35 + return nil unless owner
36 +
37 + repository = owner.repositories.find_by(name: params[:repository])
38 + return nil if repository.nil? || repository.is_private
39 +
40 + repository
41 + end
42 +
43 + # Conditional-GET + cache wrapper. Yields the PNG bytes only on a cache miss;
44 + # on any rendering failure (e.g. libvips missing) falls back to a static image
45 + # with a short TTL so the failure self-heals.
46 + def serve_card(version)
47 + return unless stale?(etag: version, public: true)
48 +
49 + png = Rails.cache.fetch([ "og", version ], expires_in: CACHE_TTL) { yield }
50 + expires_in CACHE_TTL, public: true
51 + send_data png, type: "image/png", disposition: "inline"
52 + rescue StandardError => e
53 + Rails.logger.warn("[OG] falling back to static card: #{e.class}: #{e.message}")
54 + expires_in FALLBACK_CACHE_TTL, public: true
55 + send_file Rails.public_path.join(FALLBACK_IMAGE), type: "image/png", disposition: "inline"
56 + end
57 +end
app/controllers/passwords_controller.rb
+1
@@ -1,6 +1,7 @@
1 # frozen_string_literal: true
2
3 class PasswordsController < ApplicationController
4 + before_action :noindex!
5 before_action :redirect_if_signed_in
6
7 # GET /auth/password/reset
app/controllers/registrations_controller.rb
+1
@@ -1,6 +1,7 @@
1 # frozen_string_literal: true
2
3 class RegistrationsController < ApplicationController
4 + before_action :noindex!
5 before_action :redirect_if_signed_in
6
7 # GET /auth/signup
app/controllers/repositories_controller.rb
+1
@@ -1,5 +1,6 @@
1 class RepositoriesController < ApplicationController
2 before_action :require_sign_in!, only: [ :new, :create ]
3 + before_action :noindex!, only: [ :new, :create ]
4 before_action :load_owner, only: [ :show, :tree, :cicd ]
5 before_action :load_repository, only: [ :show, :tree, :cicd ]
6 before_action :ensure_can_read!, only: [ :show, :tree, :cicd ]
app/controllers/sessions_controller.rb
+1
@@ -1,6 +1,7 @@
1 # frozen_string_literal: true
2
3 class SessionsController < ApplicationController
4 + before_action :noindex!
5 before_action :redirect_if_signed_in, only: [:new, :create]
6
7 # GET /auth/smbcloud
app/controllers/users_controller.rb
+1
@@ -1,5 +1,6 @@
1 class UsersController < ApplicationController
2 before_action :require_sign_in!, only: [ :settings, :update_settings ]
3 + before_action :noindex!, only: [ :settings, :update_settings ]
4
5 # Username whose profile shows the curated showcase feed instead of a
6 # real activity feed.
app/helpers/seo_helper.rb
+45 -4
@@ -20,10 +20,14 @@ module SeoHelper
20 "open-weights models with full model cards and LFS-backed files, and hand " \
21 "the keys to your AI coding agent over the Agent Client Protocol."
22
23 - # Square PWA icon used as the Open Graph / Twitter fallback image. A dedicated
24 - # 1200×630 share image would render larger in social previews; drop one at
25 - # /og-cover.png and point DEFAULT_OG_IMAGE_PATH at it to upgrade.
26 - DEFAULT_OG_IMAGE_PATH = "/favicon/web-app-manifest-512x512.png"
23 + # Sitewide default Open Graph / Twitter image: the generic 1200×630 card
24 + # rendered by OgImagesController#default (falls back to a static icon if image
25 + # rasterization is unavailable). Per-page repo cards override this.
26 + DEFAULT_OG_IMAGE_PATH = "/og.png"
27 +
28 + # All generated cards are 1200×630, the summary_large_image sweet spot.
29 + OG_IMAGE_WIDTH = 1200
30 + OG_IMAGE_HEIGHT = 630
31
32 # "<page> · siGit Code & Deploy", or just the suffix on the bare root.
33 def page_full_title
@@ -48,7 +52,11 @@ module SeoHelper
52 "#{request.base_url}#{custom.presence || DEFAULT_OG_IMAGE_PATH}"
53 end
54
55 + # Controllers flag auth-gated / transactional pages with `noindex!`; those
56 + # always win so private surfaces never enter a search index even if a stray
57 + # link points at them. Otherwise honor a per-view override, then the default.
58 def meta_robots
59 + return "noindex, nofollow" if @noindex
60 content_for(:robots).presence || "index, follow"
61 end
62
@@ -56,6 +64,39 @@ module SeoHelper
64 content_for(:og_type).presence || "website"
65 end
66
67 + # Path to a repository's generated Open Graph card. Used both as the page's
68 + # og:image override and inside its SoftwareSourceCode JSON-LD.
69 + def repository_og_image_path(repository)
70 + og_image_path(repository.user.username, repository.name)
71 + end
72 +
73 + # SoftwareSourceCode structured data for a public repository page. Rendered
74 + # into <script type="application/ld+json"> via content_for(:structured_data).
75 + def repository_structured_data(repository)
76 + owner = repository.user
77 + url = repository_url(owner.username, repository.name)
78 + data = {
79 + "@context" => "https://schema.org",
80 + "@type" => "SoftwareSourceCode",
81 + "name" => repository.name,
82 + "description" => meta_description,
83 + "url" => url,
84 + "codeRepository" => url,
85 + "dateCreated" => repository.created_at.utc.iso8601,
86 + "dateModified" => repository.updated_at.utc.iso8601,
87 + "image" => "#{request.base_url}#{repository_og_image_path(repository)}",
88 + "author" => {
89 + "@type" => "Person",
90 + "name" => owner.username,
91 + "url" => user_profile_url(owner.username)
92 + }
93 + }
94 + if (lang = repository.primary_language)
95 + data["programmingLanguage"] = lang.first
96 + end
97 + tag.script(data.to_json.html_safe, type: "application/ld+json")
98 + end
99 +
100 # Organization + WebSite, emitted on every page. Establishes the site entity
101 # for knowledge-panel / sitelinks eligibility.
102 def organization_json_ld
app/models/repository.rb
+78
@@ -53,6 +53,66 @@ class Repository < ApplicationRecord
53 stars.exists?(user_id: user.id)
54 end
55
56 + # ── SEO / social-card metadata ──────────────────────────────────────────
57 + #
58 + # Common source-file extensions → { name, color }. Colors mirror GitHub
59 + # Linguist so the language dot reads as expected. Intentionally small: the
60 + # goal is a good guess for the share card, not exhaustive coverage.
61 + LANGUAGE_BY_EXT = {
62 + "rb" => [ "Ruby", "#701516" ], "py" => [ "Python", "#3572A5" ],
63 + "js" => [ "JavaScript", "#f1e05a" ], "mjs" => [ "JavaScript", "#f1e05a" ],
64 + "ts" => [ "TypeScript", "#3178c6" ], "tsx" => [ "TypeScript", "#3178c6" ],
65 + "jsx" => [ "JavaScript", "#f1e05a" ], "go" => [ "Go", "#00ADD8" ],
66 + "rs" => [ "Rust", "#dea584" ], "java" => [ "Java", "#b07219" ],
67 + "kt" => [ "Kotlin", "#A97BFF" ], "swift" => [ "Swift", "#F05138" ],
68 + "c" => [ "C", "#555555" ], "h" => [ "C", "#555555" ],
69 + "cpp" => [ "C++", "#f34b7d" ], "cc" => [ "C++", "#f34b7d" ], "hpp" => [ "C++", "#f34b7d" ],
70 + "cs" => [ "C#", "#178600" ], "php" => [ "PHP", "#4F5D95" ],
71 + "rb.erb" => [ "Ruby", "#701516" ], "erb" => [ "HTML", "#e34c26" ],
72 + "html" => [ "HTML", "#e34c26" ], "css" => [ "CSS", "#563d7c" ],
73 + "scss" => [ "SCSS", "#c6538c" ], "vue" => [ "Vue", "#41b883" ],
74 + "ex" => [ "Elixir", "#6e4a7e" ], "exs" => [ "Elixir", "#6e4a7e" ],
75 + "scala" => [ "Scala", "#c22d40" ], "sh" => [ "Shell", "#89e051" ],
76 + "lua" => [ "Lua", "#000080" ], "dart" => [ "Dart", "#00B4AB" ],
77 + "r" => [ "R", "#198CE7" ], "jl" => [ "Julia", "#a270ba" ],
78 + "ipynb" => [ "Jupyter Notebook", "#DA5B0B" ], "sql" => [ "SQL", "#e38c00" ],
79 + "md" => [ "Markdown", "#083fa1" ]
80 + }.freeze
81 +
82 + WEIGHT_EXTS = %w[safetensors gguf bin onnx pt pth h5 ckpt].freeze
83 +
84 + # A short, human description for titles/social cards, with sane fallbacks so
85 + # tags are never empty even when the repo has no description.
86 + def seo_description
87 + return description.strip if description.present?
88 +
89 + if model?
90 + "#{name} — an open-weights model by @#{user.username}, with a full model " \
91 + "card and LFS-backed weights on siGit."
92 + else
93 + "#{name} — a Git repository by @#{user.username} on siGit, Git hosting " \
94 + "built for AI workflows."
95 + end
96 + end
97 +
98 + # Best-guess primary language as [name, color], or nil. For model repos that
99 + # carry weight files we surface the weight format instead of a code language.
100 + # Memoized; walks the tree once on first call.
101 + def primary_language
102 + return @primary_language if defined?(@primary_language)
103 + @primary_language = detect_primary_language
104 + end
105 +
106 + # Opaque content version for the repo's social card — changes whenever the
107 + # tip commit, name, description, or star count changes, so cached cards stay
108 + # correct without manual busting.
109 + def og_version
110 + head = initialized? ? GitRepositoryService.head_sha(disk_path, default_branch) : nil
111 + Digest::SHA256.hexdigest(
112 + [ id, name, description, stars_count, head, updated_at.to_i ].join("|")
113 + )[0, 16]
114 + end
115 +
116 # Parses the Hugging Face-style YAML front-matter from the README (the "model
117 # card") on the default branch. Memoized per instance; returns a ModelCard
118 # even when there is no front-matter so callers can use it unconditionally.
@@ -66,6 +126,24 @@ class Repository < ApplicationRecord
126
127 private
128
129 + def detect_primary_language
130 + return nil unless initialized?
131 + files = GitRepositoryService.tree_filenames(disk_path, default_branch)
132 + return nil if files.empty?
133 +
134 + counts = Hash.new(0)
135 + files.each do |f|
136 + ext = File.extname(f).delete_prefix(".").downcase
137 + next if ext.empty?
138 + if WEIGHT_EXTS.include?(ext)
139 + counts[[ "#{ext.upcase} weights", "#92D3A2" ]] += 1
140 + elsif (lang = LANGUAGE_BY_EXT[ext])
141 + counts[lang] += 1
142 + end
143 + end
144 + counts.max_by { |_lang, n| n }&.first
145 + end
146 +
147 def no_consecutive_dots_in_name
148 return if name.blank?
149 errors.add(:name, "must not contain consecutive dots") if name.include?("..")
app/services/git_repository_service.rb
+20 -2
@@ -39,7 +39,7 @@ class GitRepositoryService
39
40 def self.list_tree(path, branch, tree_path = "")
41 prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/"
42 - args = ["git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}"]
42 + args = [ "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}" ]
43 out, _err, status = Open3.capture3(*args)
44 return [] unless status.success?
45 out.lines.filter_map do |line|
@@ -50,7 +50,7 @@ class GitRepositoryService
50 { mode: mode, type: type, name: name.strip,
51 path: prefix + name.strip,
52 size: (size == "-" ? nil : size.to_i) }
53 - end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] }
53 + end.sort_by { |e| [ e[:type] == "tree" ? 0 : 1, e[:name].downcase ] }
54 end
55
56 # Git LFS / Xet pointer files are tiny text stubs that stand in for large
@@ -166,4 +166,22 @@ class GitRepositoryService
166 return 0 unless status.success?
167 out.strip.to_i
168 end
169 +
170 + # SHA of the tip commit on +branch+, or nil. Cheap single call — used as the
171 + # content version when caching derived artifacts (e.g. Open Graph cards) so
172 + # they regenerate exactly when the repository content changes.
173 + def self.head_sha(path, branch)
174 + out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "#{branch}^{commit}")
175 + status.success? ? out.strip.presence : nil
176 + end
177 +
178 + # Flat list of every tracked file path on +branch+. One `ls-tree -r` call;
179 + # used for cheap primary-language detection.
180 + def self.tree_filenames(path, branch)
181 + out, _err, status = Open3.capture3(
182 + "git", "--git-dir", path, "ls-tree", "-r", "--name-only", branch
183 + )
184 + return [] unless status.success?
185 + out.lines.map(&:strip).reject(&:blank?)
186 + end
187 end
app/services/og_image_service.rb new
+166
@@ -0,0 +1,166 @@
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
app/views/blobs/show.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "#{@filename} · #{@repository.full_name}" %>
2 +<%= render "shared/repository_social", repository: @repository %>
3
4 <div class="border-b border-surface-600 bg-surface-800">
5 <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
app/views/repositories/cicd.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "CI/CD · #{@repository.full_name}" %>
2 +<%= render "shared/repository_social", repository: @repository %>
3
4 <div class="border-b border-surface-600 bg-surface-800">
5 <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
app/views/repositories/model.html.erb
+2
@@ -1,4 +1,6 @@
1 <% content_for :title, @repository.full_name %>
2 +<%= render "shared/repository_social", repository: @repository %>
3 +<% content_for :structured_data, repository_structured_data(@repository) %>
4 <% card = @card || @repository.model_card %>
5
6 <div data-controller="tabs" data-tabs-active-value="<%= params[:tab] || 'card' %>">
app/views/repositories/show.html.erb
+2
@@ -1,4 +1,6 @@
1 <% content_for :title, @repository.full_name %>
2 +<%= render "shared/repository_social", repository: @repository %>
3 +<% content_for :structured_data, repository_structured_data(@repository) %>
4
5 <div class="border-b border-surface-600 bg-surface-800">
6 <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
app/views/repositories/tree.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "#{@current_path} · #{@repository.full_name}" %>
2 +<%= render "shared/repository_social", repository: @repository %>
3
4 <div class="border-b border-surface-600 bg-surface-800">
5 <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
app/views/shared/_repository_social.html.erb new
+6
@@ -0,0 +1,6 @@
1 +<%# Per-repo Open Graph / Twitter overrides. Only ever rendered for public
2 + repositories (private repos 404 before any view renders), so it is safe to
3 + expose name/description/owner here. %>
4 +<% content_for :description, repository.seo_description %>
5 +<% content_for :og_image, repository_og_image_path(repository) %>
6 +<% content_for :og_image_alt, "#{repository.full_name} — #{repository.model? ? "model" : "repository"} on siGit" %>
app/views/shared/_seo.html.erb
+5 -1
@@ -11,7 +11,10 @@
11 <meta property="og:description" content="<%= meta_description %>">
12 <meta property="og:url" content="<%= canonical_url %>">
13 <meta property="og:image" content="<%= og_image_url %>">
14 -<meta property="og:image:alt" content="<%= SeoHelper::SITE_NAME %> — Git hosting for the AI era">
14 +<meta property="og:image:type" content="image/png">
15 +<meta property="og:image:width" content="<%= SeoHelper::OG_IMAGE_WIDTH %>">
16 +<meta property="og:image:height" content="<%= SeoHelper::OG_IMAGE_HEIGHT %>">
17 +<meta property="og:image:alt" content="<%= content_for(:og_image_alt).presence || "#{SeoHelper::SITE_NAME} — Git hosting for the AI era" %>">
18 <meta property="og:locale" content="en_US">
19
20 <%# ── Twitter Card ── %>
@@ -19,6 +22,7 @@
22 <meta name="twitter:title" content="<%= content_for(:title).presence || SeoHelper::TITLE_SUFFIX %>">
23 <meta name="twitter:description" content="<%= meta_description %>">
24 <meta name="twitter:image" content="<%= og_image_url %>">
25 +<meta name="twitter:image:alt" content="<%= content_for(:og_image_alt).presence || "#{SeoHelper::SITE_NAME} — Git hosting for the AI era" %>">
26
27 <%# ── Structured data ── %>
28 <%= tag.script organization_json_ld.to_json.html_safe, type: "application/ld+json" %>
config/routes.rb
+8
@@ -12,6 +12,14 @@ Rails.application.routes.draw do
12 # XML sitemap for search engines (referenced from public/robots.txt).
13 get "/sitemap.xml", to: "sitemaps#index", defaults: { format: "xml" }, as: :sitemap
14
15 + # Open Graph share-card images (1200×630 PNG). Rendered server-side and cached
16 + # so social/crawler unfurls get a real preview card. Declared before the
17 + # "/:username" matcher; the dotted-name constraint mirrors the repo routes so
18 + # names like "Qwen2.5-GGUF" survive. `format: false` keeps the literal ".png".
19 + get "/og.png", to: "og_images#default", as: :og_default_image, format: false
20 + get "/og/:username/:repository.png", to: "og_images#show", as: :og_image,
21 + constraints: { repository: /[^\/.][^\/]*/ }, format: false
22 +
23 # Dev panel — only mounted in development
24 if Rails.env.development?
25 namespace :dev do
docs/seo-and-social-unfurls.md new
+72
@@ -0,0 +1,72 @@
1 +# Social link unfurls & SEO for public repos
2 +
3 +How pasted siGit links become preview cards + backlinks, and how to verify it.
4 +
5 +## What's emitted
6 +
7 +Every page renders, in the **initial server HTML** (no JS required):
8 +
9 +- `<title>`, `<meta name="description">`, `<link rel="canonical">`
10 +- Open Graph: `og:type`, `og:site_name`, `og:title`, `og:description`, `og:url`,
11 + `og:image` (+ `og:image:type/width/height/alt`), `og:locale`
12 +- Twitter: `twitter:card=summary_large_image`, title, description, image, alt
13 +- JSON-LD: `Organization` + `WebSite` sitewide; `SoftwareSourceCode` on public
14 + repo pages.
15 +
16 +Public repo pages (`repositories#show`, `#tree`, `#cicd`, `blobs#show`,
17 +`repositories#model`) override the defaults with the repo's name, owner, and
18 +description via `app/views/shared/_repository_social.html.erb` + `SeoHelper`.
19 +
20 +Auth / transactional pages (`/auth*`, `/settings`, `/billing`, `/new`) call
21 +`noindex!` in their controller, emitting `robots: noindex, nofollow`.
22 +
23 +## OG share-card images
24 +
25 +`GET /og/:owner/:repo.png` → a 1200×630 PNG (repo name, owner, wrapped
26 +description, primary-language dot, star count, siGit wordmark). `GET /og.png` is
27 +the generic sitewide card and the default `og:image`.
28 +
29 +- Rendered by `OgImageService` (SVG template → PNG via **libvips**; the prod
30 + image installs `libvips`, `librsvg2-2`, and `fonts-dejavu-core`).
31 +- Cached by content hash (`Repository#og_version`: tip commit + name +
32 + description + stars). Served `public`, long max-age, with an `ETag` so repeat
33 + fetches are `304`s. Never generated on the hot path when cached.
34 +- **Privacy:** private repos `404` here (no card, even for the owner) and are
35 + excluded from the sitemap. A logged-out crawler hitting a private repo URL
36 + gets the static `404` with zero metadata.
37 +- **Fallback:** if rasterization is unavailable, the endpoint serves the static
38 + brand icon with a short TTL so it self-heals — it never 500s.
39 +
40 +## robots.txt / sitemap.xml
41 +
42 +- `public/robots.txt` allows content, disallows `/auth /settings /billing /new
43 + /api/ /*/raw/`, and points at the sitemap.
44 +- `GET /sitemap.xml` (`SitemapsController`) lists marketing/legal pages, every
45 + public repo, and the profiles that own one. Private repos and
46 + private-only users are excluded. Cached 6h.
47 +
48 +## Verify locally
49 +
50 +```bash
51 +# Meta tags are in the initial HTML (simulate a crawler):
52 +curl -s -A "Twitterbot" http://localhost:3000/<owner>/<repo> | grep -iE 'og:|twitter:|canonical|application/ld'
53 +
54 +# Private repo leaks nothing and 404s:
55 +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/<owner>/<private-repo> # 404
56 +
57 +# OG image (needs libvips locally; otherwise returns the static fallback PNG):
58 +curl -sI http://localhost:3000/og/<owner>/<repo>.png # 200, Content-Type: image/png, ETag, Cache-Control: public
59 +curl -s http://localhost:3000/robots.txt
60 +curl -s http://localhost:3000/sitemap.xml | head
61 +
62 +# Tests:
63 +bin/rails test
64 +```
65 +
66 +External validators (run against a deployed public URL): X Card Validator,
67 +Facebook Sharing Debugger, LinkedIn Post Inspector, Google Rich Results Test.
68 +
69 +> Note: macOS dev hosts usually lack libvips, so the OG endpoint returns the
70 +> static fallback locally — the card itself renders in CI/production. To preview
71 +> the card design without libvips, dump the SVG
72 +> (`OgImageService.send(:repository_svg, repo)`) and open it in a browser.
public/robots.txt
+1
@@ -6,6 +6,7 @@ Allow: /
6 Disallow: /auth
7 Disallow: /settings
8 Disallow: /billing
9 +Disallow: /new
10 Disallow: /api/
11
12 # Raw file endpoints serve bytes, not pages.
test/controllers/og_images_controller_test.rb new
+54
@@ -0,0 +1,54 @@
1 +# frozen_string_literal: true
2 +
3 +require "test_helper"
4 +
5 +# The OG endpoint must always answer crawlers with a PNG (a generated card where
6 +# libvips is available, a static fallback otherwise), cache aggressively, and
7 +# never render a card for a private repo.
8 +class OgImagesControllerTest < ActionDispatch::IntegrationTest
9 + setup do
10 + @public = repositories(:public_code)
11 + @model = repositories(:public_model)
12 + @private = repositories(:private_code)
13 + end
14 +
15 + test "public repo OG image returns a cacheable PNG" do
16 + get "/og/#{@public.user.username}/#{@public.name}.png"
17 + assert_response :success
18 + assert_equal "image/png", response.media_type
19 + assert_includes response.headers["Cache-Control"], "public"
20 + assert response.headers["ETag"].present?, "expected an ETag for conditional GETs"
21 + end
22 +
23 + test "dotted repo names route correctly" do
24 + get "/og/#{@model.user.username}/#{@model.name}.png"
25 + assert_response :success
26 + assert_equal "image/png", response.media_type
27 + end
28 +
29 + test "second hit with matching ETag is served from cache as 304" do
30 + get "/og/#{@public.user.username}/#{@public.name}.png"
31 + etag = response.headers["ETag"]
32 + assert etag.present?
33 +
34 + get "/og/#{@public.user.username}/#{@public.name}.png",
35 + headers: { "If-None-Match" => etag }
36 + assert_response :not_modified
37 + end
38 +
39 + test "default sitewide card returns a PNG" do
40 + get "/og.png"
41 + assert_response :success
42 + assert_equal "image/png", response.media_type
43 + end
44 +
45 + test "private repo OG image is not rendered (404)" do
46 + get "/og/#{@private.user.username}/#{@private.name}.png"
47 + assert_response :not_found
48 + end
49 +
50 + test "unknown repo OG image is 404" do
51 + get "/og/alice/does-not-exist.png"
52 + assert_response :not_found
53 + end
54 +end
test/fixtures/repositories.yml new
+29
@@ -0,0 +1,29 @@
1 +public_code:
2 + user: alice
3 + name: widget
4 + description: A tiny widget library for building UIs fast.
5 + default_branch: main
6 + kind: code
7 + disk_path: /nonexistent/alice/widget.git
8 + is_private: false
9 + stars_count: 7
10 +
11 +public_model:
12 + user: alice
13 + name: Qwen2.5-GGUF
14 + description: Quantized GGUF weights for fast local inference.
15 + default_branch: main
16 + kind: model
17 + disk_path: /nonexistent/alice/qwen.git
18 + is_private: false
19 + stars_count: 0
20 +
21 +private_code:
22 + user: bob
23 + name: secret-internal-tool
24 + description: TOPSECRETDESCRIPTION should never leak to crawlers.
25 + default_branch: main
26 + kind: code
27 + disk_path: /nonexistent/bob/secret.git
28 + is_private: true
29 + stars_count: 3
test/fixtures/users.yml new
+11
@@ -0,0 +1,11 @@
1 +alice:
2 + username: alice
3 + email: alice@example.com
4 + smbcloud_id: 1001
5 + display_name: Alice
6 +
7 +bob:
8 + username: bob
9 + email: bob@example.com
10 + smbcloud_id: 1002
11 + display_name: Bob
test/integration/repository_seo_test.rb new
+60
@@ -0,0 +1,60 @@
1 +# frozen_string_literal: true
2 +
3 +require "test_helper"
4 +
5 +# Verifies the server-rendered SEO/social meta tags for public vs private repos.
6 +# Crawlers don't run JS, so everything asserted here must be in the initial HTML.
7 +class RepositorySeoTest < ActionDispatch::IntegrationTest
8 + setup do
9 + @public = repositories(:public_code)
10 + @private = repositories(:private_code)
11 + end
12 +
13 + test "public repo page emits Open Graph, Twitter, and canonical tags server-side" do
14 + get "/#{@public.user.username}/#{@public.name}"
15 + assert_response :success
16 +
17 + assert_includes response.body, %(property="og:title" content="alice/widget")
18 + assert_includes response.body, %(property="og:site_name" content="siGit")
19 + assert_includes response.body, %(property="og:image" content="http://www.example.com/og/alice/widget.png")
20 + assert_includes response.body, %(property="og:image:width" content="1200")
21 + assert_includes response.body, %(property="og:image:height" content="630")
22 + assert_includes response.body, %(name="twitter:card" content="summary_large_image")
23 + assert_includes response.body, %(rel="canonical" href="http://www.example.com/alice/widget")
24 + assert_includes response.body, "A tiny widget library"
25 + end
26 +
27 + test "public repo page emits SoftwareSourceCode JSON-LD" do
28 + get "/#{@public.user.username}/#{@public.name}"
29 + assert_response :success
30 + assert_includes response.body, %("@type":"SoftwareSourceCode")
31 + assert_includes response.body, %("codeRepository":"http://www.example.com/alice/widget")
32 + end
33 +
34 + test "public repo page is indexable" do
35 + get "/#{@public.user.username}/#{@public.name}"
36 + assert_includes response.body, %(name="robots" content="index, follow")
37 + end
38 +
39 + test "private repo returns 404 and leaks no metadata to a logged-out crawler" do
40 + get "/#{@private.user.username}/#{@private.name}"
41 + assert_response :not_found
42 + refute_includes response.body, "TOPSECRETDESCRIPTION"
43 + refute_includes response.body, "secret-internal-tool"
44 + refute_includes response.body, "og/bob"
45 + end
46 +
47 + test "auth pages are marked noindex" do
48 + get "/auth"
49 + assert_response :success
50 + assert_includes response.body, %(name="robots" content="noindex, nofollow")
51 + end
52 +
53 + test "empty-description repo still gets a non-empty description tag" do
54 + @public.update_column(:description, nil)
55 + get "/#{@public.user.username}/#{@public.name}"
56 + assert_response :success
57 + # Falls back to the generated seo_description rather than an empty tag.
58 + assert_includes response.body, "Git hosting built for AI workflows"
59 + end
60 +end
test/integration/sitemap_robots_test.rb new
+35
@@ -0,0 +1,35 @@
1 +# frozen_string_literal: true
2 +
3 +require "test_helper"
4 +
5 +# robots.txt and sitemap.xml must expose public content and exclude anything
6 +# private or behind auth.
7 +class SitemapRobotsTest < ActionDispatch::IntegrationTest
8 + test "sitemap lists public repos and their owners" do
9 + get "/sitemap.xml"
10 + assert_response :success
11 + assert_equal "application/xml", response.media_type
12 +
13 + assert_includes response.body, "http://www.example.com/alice/widget"
14 + assert_includes response.body, "http://www.example.com/alice/Qwen2.5-GGUF"
15 + assert_includes response.body, "http://www.example.com/alice"
16 + end
17 +
18 + test "sitemap excludes private repos and users who only have private repos" do
19 + get "/sitemap.xml"
20 + refute_includes response.body, "secret-internal-tool"
21 + refute_includes response.body, "http://www.example.com/bob"
22 + end
23 +
24 + test "robots.txt allows content and disallows auth/transactional routes" do
25 + get "/robots.txt"
26 + assert_response :success
27 +
28 + body = response.body
29 + assert_includes body, "Sitemap: https://sigit.si/sitemap.xml"
30 + assert_includes body, "Disallow: /settings"
31 + assert_includes body, "Disallow: /billing"
32 + assert_includes body, "Disallow: /auth"
33 + assert_includes body, "Disallow: /api/"
34 + end
35 +end
test/models/repository_seo_test.rb new
+35
@@ -0,0 +1,35 @@
1 +# frozen_string_literal: true
2 +
3 +require "test_helper"
4 +
5 +class RepositoryModelSeoTest < ActiveSupport::TestCase
6 + test "seo_description uses the description when present" do
7 + repo = repositories(:public_code)
8 + assert_equal "A tiny widget library for building UIs fast.", repo.seo_description
9 + end
10 +
11 + test "seo_description falls back for a code repo without a description" do
12 + repo = repositories(:public_code)
13 + repo.description = nil
14 + assert_includes repo.seo_description, "@alice"
15 + assert_includes repo.seo_description, "Git hosting built for AI workflows"
16 + end
17 +
18 + test "seo_description falls back for a model repo without a description" do
19 + repo = repositories(:public_model)
20 + repo.description = nil
21 + assert_includes repo.seo_description, "open-weights model"
22 + end
23 +
24 + test "og_version changes when share-card inputs change" do
25 + repo = repositories(:public_code)
26 + before = repo.og_version
27 + repo.stars_count = repo.stars_count + 1
28 + refute_equal before, repo.og_version
29 + end
30 +
31 + test "primary_language is nil for an uninitialized repo" do
32 + # Fixture disk_path does not exist on disk.
33 + assert_nil repositories(:public_code).primary_language
34 + end
35 +end
test/services/og_image_service_test.rb new
+44
@@ -0,0 +1,44 @@
1 +# frozen_string_literal: true
2 +
3 +require "test_helper"
4 +
5 +class OgImageServiceTest < ActiveSupport::TestCase
6 + test "repository SVG includes the repo name, owner, and is well-formed" do
7 + repo = repositories(:public_code)
8 + svg = OgImageService.send(:repository_svg, repo)
9 +
10 + assert svg.start_with?("<svg")
11 + assert_includes svg, "@alice /"
12 + assert_includes svg, "widget"
13 + assert_includes svg, %(width="1200")
14 + assert_includes svg, %(height="630")
15 + end
16 +
17 + test "user-controlled text is XML-escaped to prevent SVG injection" do
18 + repo = repositories(:public_code)
19 + repo.name = %q{x"><script>alert(1)</script>}
20 + repo.description = "evil & <b>markup</b>"
21 + svg = OgImageService.send(:repository_svg, repo)
22 +
23 + refute_includes svg, "<script>"
24 + assert_includes svg, "&lt;script&gt;"
25 + assert_includes svg, "&amp;"
26 + end
27 +
28 + test "default card is generated without a repository" do
29 + svg = OgImageService.send(:default_svg)
30 + assert svg.start_with?("<svg")
31 + assert_includes svg, "Git hosting for the AI era"
32 + end
33 +
34 + test "render raises a typed error when libvips/SVG support is unavailable" do
35 + # In CI/local without libvips this exercises the fallback path the
36 + # controller relies on; where libvips exists it returns PNG bytes instead.
37 + begin
38 + png = OgImageService.render_default
39 + assert png.start_with?("\x89PNG".b), "expected PNG magic bytes"
40 + rescue OgImageService::Error
41 + assert true # graceful, controller-catchable failure
42 + end
43 + end
44 +end
test/test_helper.rb new
+15
@@ -0,0 +1,15 @@
1 +# frozen_string_literal: true
2 +
3 +ENV["RAILS_ENV"] ||= "test"
4 +require_relative "../config/environment"
5 +require "rails/test_help"
6 +
7 +module ActiveSupport
8 + class TestCase
9 + # Run tests in parallel with specified workers
10 + parallelize(workers: :number_of_processors)
11 +
12 + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
13 + fixtures :all
14 + end
15 +end