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 committedJun 30, 2026 at 19:21 UTC7fa3462344b242b9bb380858d2d573a5f084aca9
32 files changed+765-9
Dockerfile
+3-2
index 178d07c..c9e492f 100644--- a/Dockerfile+++ b/Dockerfile@@ -14,9 +14,10 @@ FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base # Rails app lives here WORKDIR /rails-# Install base packages+# Install base packages. librsvg2-2 + a base font give libvips SVG-with-text+# rasterization for the Open Graph share cards (OgImageService). RUN apt-get update -qq && \- apt-get install --no-install-recommends -y curl libjemalloc2 libvips postgresql-client && \+ apt-get install --no-install-recommends -y curl libjemalloc2 libvips librsvg2-2 fonts-dejavu-core postgresql-client && \ ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives
app/controllers/application_controller.rb
+7
index 2fcb6a4..cbb0673 100644--- a/app/controllers/application_controller.rb+++ b/app/controllers/application_controller.rb@@ -25,4 +25,11 @@ class ApplicationController < ActionController::Base redirect_to signin_path, alert: "Please sign in to continue." end end++ # Marks the current response as off-limits to search engines. The SEO partial+ # reads @noindex and emits <meta name="robots" content="noindex, nofollow">.+ # Use for auth, settings, billing, and other non-content/transactional pages.+ def noindex!+ @noindex = true+ end end
app/controllers/billing_controller.rb
+1
index 13846ad..6386aaf 100644--- a/app/controllers/billing_controller.rb+++ b/app/controllers/billing_controller.rb@@ -5,6 +5,7 @@ # the web so the desktop app can link here and avoid the App Store cut. class BillingController < ApplicationController before_action :require_sign_in!+ before_action :noindex! def show @subscription = current_user.subscription
app/controllers/confirmations_controller.rb
+1
index 9ec3c04..407ae27 100644--- a/app/controllers/confirmations_controller.rb+++ b/app/controllers/confirmations_controller.rb@@ -1,6 +1,7 @@ # frozen_string_literal: true class ConfirmationsController < ApplicationController+ before_action :noindex! before_action :redirect_if_signed_in # GET /auth/confirmation/resend
app/controllers/og_images_controller.rb
+57
new file mode 100644index 0000000..e3b34a5--- /dev/null+++ b/app/controllers/og_images_controller.rb@@ -0,0 +1,57 @@+# frozen_string_literal: true++# Serves the Open Graph share-card PNGs referenced by the OG/Twitter meta tags.+#+# Cards are cached by content hash (OgImageService never runs on the hot path+# when a cached card exists) and answered with a long-lived, public, ETag'd+# response so crawlers and CDNs only fetch bytes once. Private repositories are+# never rendered — the endpoint 404s so no preview can leak.+class OgImagesController < ApplicationController+ CACHE_TTL = 7.days+ FALLBACK_IMAGE = "favicon/web-app-manifest-512x512.png"+ FALLBACK_CACHE_TTL = 5.minutes++ # GET /og/:username/:repository.png+ def show+ repository = public_repository+ return head :not_found unless repository++ serve_card("#{OgImageService::TEMPLATE_VERSION}-#{repository.og_version}") do+ OgImageService.render(repository: repository)+ end+ end++ # GET /og.png — the generic sitewide card (default og:image).+ def default+ serve_card("#{OgImageService::TEMPLATE_VERSION}-default") do+ OgImageService.render_default+ end+ end++ private++ def public_repository+ owner = User.find_by(username: params[:username])+ return nil unless owner++ repository = owner.repositories.find_by(name: params[:repository])+ return nil if repository.nil? || repository.is_private++ repository+ end++ # Conditional-GET + cache wrapper. Yields the PNG bytes only on a cache miss;+ # on any rendering failure (e.g. libvips missing) falls back to a static image+ # with a short TTL so the failure self-heals.+ def serve_card(version)+ return unless stale?(etag: version, public: true)++ png = Rails.cache.fetch([ "og", version ], expires_in: CACHE_TTL) { yield }+ expires_in CACHE_TTL, public: true+ send_data png, type: "image/png", disposition: "inline"+ rescue StandardError => e+ Rails.logger.warn("[OG] falling back to static card: #{e.class}: #{e.message}")+ expires_in FALLBACK_CACHE_TTL, public: true+ send_file Rails.public_path.join(FALLBACK_IMAGE), type: "image/png", disposition: "inline"+ end+end
app/controllers/passwords_controller.rb
+1
index edb1c16..5803aa1 100644--- a/app/controllers/passwords_controller.rb+++ b/app/controllers/passwords_controller.rb@@ -1,6 +1,7 @@ # frozen_string_literal: true class PasswordsController < ApplicationController+ before_action :noindex! before_action :redirect_if_signed_in # GET /auth/password/reset
app/controllers/registrations_controller.rb
+1
index cac70e7..da5259e 100644--- a/app/controllers/registrations_controller.rb+++ b/app/controllers/registrations_controller.rb@@ -1,6 +1,7 @@ # frozen_string_literal: true class RegistrationsController < ApplicationController+ before_action :noindex! before_action :redirect_if_signed_in # GET /auth/signup
index 7f6b991..2e59fa0 100644--- a/app/controllers/sessions_controller.rb+++ b/app/controllers/sessions_controller.rb@@ -1,6 +1,7 @@ # frozen_string_literal: true class SessionsController < ApplicationController+ before_action :noindex! before_action :redirect_if_signed_in, only: [:new, :create] # GET /auth/smbcloud
app/controllers/users_controller.rb
+1
index 24c2959..fdee993 100644--- a/app/controllers/users_controller.rb+++ b/app/controllers/users_controller.rb@@ -1,5 +1,6 @@ class UsersController < ApplicationController before_action :require_sign_in!, only: [ :settings, :update_settings ]+ before_action :noindex!, only: [ :settings, :update_settings ] # Username whose profile shows the curated showcase feed instead of a # real activity feed.
app/helpers/seo_helper.rb
+45-4
index c25bef6..b1fb33d 100644--- a/app/helpers/seo_helper.rb+++ b/app/helpers/seo_helper.rb@@ -20,10 +20,14 @@ module SeoHelper "open-weights models with full model cards and LFS-backed files, and hand " \ "the keys to your AI coding agent over the Agent Client Protocol."- # Square PWA icon used as the Open Graph / Twitter fallback image. A dedicated- # 1200×630 share image would render larger in social previews; drop one at- # /og-cover.png and point DEFAULT_OG_IMAGE_PATH at it to upgrade.- DEFAULT_OG_IMAGE_PATH = "/favicon/web-app-manifest-512x512.png"+ # Sitewide default Open Graph / Twitter image: the generic 1200×630 card+ # rendered by OgImagesController#default (falls back to a static icon if image+ # rasterization is unavailable). Per-page repo cards override this.+ DEFAULT_OG_IMAGE_PATH = "/og.png"++ # All generated cards are 1200×630, the summary_large_image sweet spot.+ OG_IMAGE_WIDTH = 1200+ OG_IMAGE_HEIGHT = 630 # "<page> · siGit Code & Deploy", or just the suffix on the bare root. def page_full_title@@ -48,7 +52,11 @@ module SeoHelper "#{request.base_url}#{custom.presence || DEFAULT_OG_IMAGE_PATH}" end+ # Controllers flag auth-gated / transactional pages with `noindex!`; those+ # always win so private surfaces never enter a search index even if a stray+ # link points at them. Otherwise honor a per-view override, then the default. def meta_robots+ return "noindex, nofollow" if @noindex content_for(:robots).presence || "index, follow" end@@ -56,6 +64,39 @@ module SeoHelper content_for(:og_type).presence || "website" end+ # Path to a repository's generated Open Graph card. Used both as the page's+ # og:image override and inside its SoftwareSourceCode JSON-LD.+ def repository_og_image_path(repository)+ og_image_path(repository.user.username, repository.name)+ end++ # SoftwareSourceCode structured data for a public repository page. Rendered+ # into <script type="application/ld+json"> via content_for(:structured_data).+ def repository_structured_data(repository)+ owner = repository.user+ url = repository_url(owner.username, repository.name)+ data = {+ "@context" => "https://schema.org",+ "@type" => "SoftwareSourceCode",+ "name" => repository.name,+ "description" => meta_description,+ "url" => url,+ "codeRepository" => url,+ "dateCreated" => repository.created_at.utc.iso8601,+ "dateModified" => repository.updated_at.utc.iso8601,+ "image" => "#{request.base_url}#{repository_og_image_path(repository)}",+ "author" => {+ "@type" => "Person",+ "name" => owner.username,+ "url" => user_profile_url(owner.username)+ }+ }+ if (lang = repository.primary_language)+ data["programmingLanguage"] = lang.first+ end+ tag.script(data.to_json.html_safe, type: "application/ld+json")+ end+ # Organization + WebSite, emitted on every page. Establishes the site entity # for knowledge-panel / sitelinks eligibility. def organization_json_ld
app/models/repository.rb
+78
index 00e941e..bbcaf85 100644--- a/app/models/repository.rb+++ b/app/models/repository.rb@@ -53,6 +53,66 @@ class Repository < ApplicationRecord stars.exists?(user_id: user.id) end+ # ── SEO / social-card metadata ──────────────────────────────────────────+ #+ # Common source-file extensions → { name, color }. Colors mirror GitHub+ # Linguist so the language dot reads as expected. Intentionally small: the+ # goal is a good guess for the share card, not exhaustive coverage.+ LANGUAGE_BY_EXT = {+ "rb" => [ "Ruby", "#701516" ], "py" => [ "Python", "#3572A5" ],+ "js" => [ "JavaScript", "#f1e05a" ], "mjs" => [ "JavaScript", "#f1e05a" ],+ "ts" => [ "TypeScript", "#3178c6" ], "tsx" => [ "TypeScript", "#3178c6" ],+ "jsx" => [ "JavaScript", "#f1e05a" ], "go" => [ "Go", "#00ADD8" ],+ "rs" => [ "Rust", "#dea584" ], "java" => [ "Java", "#b07219" ],+ "kt" => [ "Kotlin", "#A97BFF" ], "swift" => [ "Swift", "#F05138" ],+ "c" => [ "C", "#555555" ], "h" => [ "C", "#555555" ],+ "cpp" => [ "C++", "#f34b7d" ], "cc" => [ "C++", "#f34b7d" ], "hpp" => [ "C++", "#f34b7d" ],+ "cs" => [ "C#", "#178600" ], "php" => [ "PHP", "#4F5D95" ],+ "rb.erb" => [ "Ruby", "#701516" ], "erb" => [ "HTML", "#e34c26" ],+ "html" => [ "HTML", "#e34c26" ], "css" => [ "CSS", "#563d7c" ],+ "scss" => [ "SCSS", "#c6538c" ], "vue" => [ "Vue", "#41b883" ],+ "ex" => [ "Elixir", "#6e4a7e" ], "exs" => [ "Elixir", "#6e4a7e" ],+ "scala" => [ "Scala", "#c22d40" ], "sh" => [ "Shell", "#89e051" ],+ "lua" => [ "Lua", "#000080" ], "dart" => [ "Dart", "#00B4AB" ],+ "r" => [ "R", "#198CE7" ], "jl" => [ "Julia", "#a270ba" ],+ "ipynb" => [ "Jupyter Notebook", "#DA5B0B" ], "sql" => [ "SQL", "#e38c00" ],+ "md" => [ "Markdown", "#083fa1" ]+ }.freeze++ WEIGHT_EXTS = %w[safetensors gguf bin onnx pt pth h5 ckpt].freeze++ # A short, human description for titles/social cards, with sane fallbacks so+ # tags are never empty even when the repo has no description.+ def seo_description+ return description.strip if description.present?++ if model?+ "#{name} — an open-weights model by @#{user.username}, with a full model " \+ "card and LFS-backed weights on siGit."+ else+ "#{name} — a Git repository by @#{user.username} on siGit, Git hosting " \+ "built for AI workflows."+ end+ end++ # Best-guess primary language as [name, color], or nil. For model repos that+ # carry weight files we surface the weight format instead of a code language.+ # Memoized; walks the tree once on first call.+ def primary_language+ return @primary_language if defined?(@primary_language)+ @primary_language = detect_primary_language+ end++ # Opaque content version for the repo's social card — changes whenever the+ # tip commit, name, description, or star count changes, so cached cards stay+ # correct without manual busting.+ def og_version+ head = initialized? ? GitRepositoryService.head_sha(disk_path, default_branch) : nil+ Digest::SHA256.hexdigest(+ [ id, name, description, stars_count, head, updated_at.to_i ].join("|")+ )[0, 16]+ end+ # Parses the Hugging Face-style YAML front-matter from the README (the "model # card") on the default branch. Memoized per instance; returns a ModelCard # even when there is no front-matter so callers can use it unconditionally.@@ -66,6 +126,24 @@ class Repository < ApplicationRecord private+ def detect_primary_language+ return nil unless initialized?+ files = GitRepositoryService.tree_filenames(disk_path, default_branch)+ return nil if files.empty?++ counts = Hash.new(0)+ files.each do |f|+ ext = File.extname(f).delete_prefix(".").downcase+ next if ext.empty?+ if WEIGHT_EXTS.include?(ext)+ counts[[ "#{ext.upcase} weights", "#92D3A2" ]] += 1+ elsif (lang = LANGUAGE_BY_EXT[ext])+ counts[lang] += 1+ end+ end+ counts.max_by { |_lang, n| n }&.first+ end+ def no_consecutive_dots_in_name return if name.blank? errors.add(:name, "must not contain consecutive dots") if name.include?("..")
app/services/git_repository_service.rb
+20-2
index edb4479..14f81c4 100644--- a/app/services/git_repository_service.rb+++ b/app/services/git_repository_service.rb@@ -39,7 +39,7 @@ class GitRepositoryService def self.list_tree(path, branch, tree_path = "") prefix = tree_path.blank? ? "" : "#{tree_path.chomp("/")}/"- args = ["git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}"]+ args = [ "git", "--git-dir", path, "ls-tree", "--long", "#{branch}:#{prefix}" ] out, _err, status = Open3.capture3(*args) return [] unless status.success? out.lines.filter_map do |line|@@ -50,7 +50,7 @@ class GitRepositoryService { mode: mode, type: type, name: name.strip, path: prefix + name.strip, size: (size == "-" ? nil : size.to_i) }- end.sort_by { |e| [e[:type] == "tree" ? 0 : 1, e[:name].downcase] }+ end.sort_by { |e| [ e[:type] == "tree" ? 0 : 1, e[:name].downcase ] } end # Git LFS / Xet pointer files are tiny text stubs that stand in for large@@ -166,4 +166,22 @@ class GitRepositoryService return 0 unless status.success? out.strip.to_i end++ # SHA of the tip commit on +branch+, or nil. Cheap single call — used as the+ # content version when caching derived artifacts (e.g. Open Graph cards) so+ # they regenerate exactly when the repository content changes.+ def self.head_sha(path, branch)+ out, _err, status = Open3.capture3("git", "--git-dir", path, "rev-parse", "--verify", "#{branch}^{commit}")+ status.success? ? out.strip.presence : nil+ end++ # Flat list of every tracked file path on +branch+. One `ls-tree -r` call;+ # used for cheap primary-language detection.+ def self.tree_filenames(path, branch)+ out, _err, status = Open3.capture3(+ "git", "--git-dir", path, "ls-tree", "-r", "--name-only", branch+ )+ return [] unless status.success?+ out.lines.map(&:strip).reject(&:blank?)+ end end
app/services/og_image_service.rb
+166
new file mode 100644index 0000000..50f54fd--- /dev/null+++ b/app/services/og_image_service.rb@@ -0,0 +1,166 @@+# frozen_string_literal: true++require "cgi"++# Renders the 1200×630 Open Graph "share card" PNGs that social platforms and+# crawlers show when a siGit URL is pasted into Slack / X / LinkedIn / Discord.+#+# Pipeline: build an SVG from the brand template, then rasterize it to PNG with+# libvips (installed in the production image). The caller is responsible for+# caching — generation is only ever invoked on a cache miss. Raises if libvips+# or its SVG/text support is unavailable so the controller can fall back to a+# static image.+class OgImageService+ WIDTH = 1200+ HEIGHT = 630++ # Bump when the template changes so cached cards regenerate.+ TEMPLATE_VERSION = "og-v1"++ # Brand palette (mirrors config/tailwind.config.js).+ BG_TOP = "#1a1d21" # surface-800+ BG_BOTTOM = "#15171a" # surface-900+ BRAND = "#FE6D36" # brand-500+ TEXT = "#f3f4f6" # gray-100+ MUTED = "#9ca3ae" # surface-300 / gray+ BORDER = "#2d3238" # surface-600++ # Fonts are substituted by fontconfig at rasterize time; the stack degrades to+ # whatever sans-serif the host has installed.+ FONT = "Inter, 'Helvetica Neue', Arial, sans-serif"++ class << self+ def render(repository:)+ rasterize(repository_svg(repository))+ end++ def render_default+ rasterize(default_svg)+ end++ private++ # ── Rasterization ──────────────────────────────────────────────────────+ def rasterize(svg)+ require "vips"+ image = Vips::Image.new_from_buffer(svg, "", access: :sequential)+ image.write_to_buffer(".png")+ rescue LoadError => e+ raise Error, "libvips unavailable: #{e.message}"+ rescue Vips::Error => e+ raise Error, "rasterization failed: #{e.message}"+ end++ # ── Templates ──────────────────────────────────────────────────────────+ def repository_svg(repository)+ owner = "@#{repository.user.username}"+ name = truncate(repository.name, 28)+ desc_lines = wrap(repository.seo_description, width: 56, max_lines: 3)+ lang = repository.primary_language+ stars = repository.stars_count.to_i++ <<~SVG+ <svg xmlns="http://www.w3.org/2000/svg" width="#{WIDTH}" height="#{HEIGHT}" viewBox="0 0 #{WIDTH} #{HEIGHT}">+ #{frame}+ #{wordmark}+ <text x="80" y="250" font-family="#{FONT}" font-size="30" fill="#{MUTED}" font-weight="500">#{esc(owner)} /</text>+ <text x="80" y="330" font-family="#{FONT}" font-size="72" fill="#{TEXT}" font-weight="700">#{esc(name)}</text>+ #{description_block(desc_lines, y: 400)}+ #{footer(lang: lang, stars: stars)}+ </svg>+ SVG+ end++ def default_svg+ <<~SVG+ <svg xmlns="http://www.w3.org/2000/svg" width="#{WIDTH}" height="#{HEIGHT}" viewBox="0 0 #{WIDTH} #{HEIGHT}">+ #{frame}+ #{wordmark}+ <text x="80" y="320" font-family="#{FONT}" font-size="68" fill="#{TEXT}" font-weight="700">Git hosting for the AI era</text>+ #{description_block([ "Version repositories, publish open-weights models", "with full model cards, and hand the keys to your", "AI coding agent." ], y: 390)}+ </svg>+ SVG+ end++ # ── Reusable fragments ───────────────────────────────────────────────────+ def frame+ <<~SVG+ <defs>+ <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">+ <stop offset="0%" stop-color="#{BG_TOP}"/>+ <stop offset="100%" stop-color="#{BG_BOTTOM}"/>+ </linearGradient>+ </defs>+ <rect width="#{WIDTH}" height="#{HEIGHT}" fill="url(#bg)"/>+ <rect width="#{WIDTH}" height="8" fill="#{BRAND}"/>+ <rect x="20" y="20" width="#{WIDTH - 40}" height="#{HEIGHT - 40}" rx="20" fill="none" stroke="#{BORDER}" stroke-width="2"/>+ SVG+ end++ # siGit wordmark, top-left.+ def wordmark+ <<~SVG+ <text x="80" y="130" font-family="#{FONT}" font-size="40" font-weight="700">+ <tspan fill="#{TEXT}">si</tspan><tspan fill="#{BRAND}">Git</tspan>+ </text>+ SVG+ end++ def description_block(lines, y:)+ lines.each_with_index.map do |line, i|+ %(<text x="80" y="#{y + (i * 46)}" font-family="#{FONT}" font-size="32" fill="#{MUTED}">#{esc(line)}</text>)+ end.join("\n")+ end++ def footer(lang:, stars:)+ parts = []+ x = 80+ if lang+ name, color = lang+ parts << %(<circle cx="#{x + 9}" cy="555" r="9" fill="#{esc(color)}"/>)+ parts << %(<text x="#{x + 30}" y="564" font-family="#{FONT}" font-size="28" fill="#{TEXT}">#{esc(name)}</text>)+ x += 50 + (name.length * 15)+ end+ if stars.positive?+ parts << %(<text x="#{x}" y="564" font-family="#{FONT}" font-size="28" fill="#{MUTED}">★ #{stars}</text>)+ end+ parts.join("\n")+ end++ # ── Text helpers ─────────────────────────────────────────────────────────+ def esc(text)+ CGI.escapeHTML(text.to_s)+ end++ def truncate(text, length)+ text = text.to_s+ text.length > length ? "#{text[0, length - 1]}…" : text+ end++ # Greedy word wrap to at most +max_lines+ lines of ~+width+ chars, with an+ # ellipsis on the last line if the text overflows.+ def wrap(text, width:, max_lines:)+ words = text.to_s.split(/\s+/)+ lines = []+ current = +""+ words.each do |word|+ candidate = current.empty? ? word : "#{current} #{word}"+ if candidate.length <= width || current.empty?+ current = candidate+ else+ lines << current+ current = word+ end+ end+ lines << current unless current.empty?++ if lines.length > max_lines+ lines = lines[0, max_lines]+ lines[-1] = "#{lines[-1]}…"+ end+ lines+ end+ end++ class Error < StandardError; end+end
index 3dcd90f..0136157 100644--- a/config/routes.rb+++ b/config/routes.rb@@ -12,6 +12,14 @@ Rails.application.routes.draw do # XML sitemap for search engines (referenced from public/robots.txt). get "/sitemap.xml", to: "sitemaps#index", defaults: { format: "xml" }, as: :sitemap+ # Open Graph share-card images (1200×630 PNG). Rendered server-side and cached+ # so social/crawler unfurls get a real preview card. Declared before the+ # "/:username" matcher; the dotted-name constraint mirrors the repo routes so+ # names like "Qwen2.5-GGUF" survive. `format: false` keeps the literal ".png".+ get "/og.png", to: "og_images#default", as: :og_default_image, format: false+ get "/og/:username/:repository.png", to: "og_images#show", as: :og_image,+ constraints: { repository: /[^\/.][^\/]*/ }, format: false+ # Dev panel — only mounted in development if Rails.env.development? namespace :dev do
docs/seo-and-social-unfurls.md
+72
new file mode 100644index 0000000..5a3ca75--- /dev/null+++ b/docs/seo-and-social-unfurls.md@@ -0,0 +1,72 @@+# Social link unfurls & SEO for public repos++How pasted siGit links become preview cards + backlinks, and how to verify it.++## What's emitted++Every page renders, in the **initial server HTML** (no JS required):++- `<title>`, `<meta name="description">`, `<link rel="canonical">`+- Open Graph: `og:type`, `og:site_name`, `og:title`, `og:description`, `og:url`,+ `og:image` (+ `og:image:type/width/height/alt`), `og:locale`+- Twitter: `twitter:card=summary_large_image`, title, description, image, alt+- JSON-LD: `Organization` + `WebSite` sitewide; `SoftwareSourceCode` on public+ repo pages.++Public repo pages (`repositories#show`, `#tree`, `#cicd`, `blobs#show`,+`repositories#model`) override the defaults with the repo's name, owner, and+description via `app/views/shared/_repository_social.html.erb` + `SeoHelper`.++Auth / transactional pages (`/auth*`, `/settings`, `/billing`, `/new`) call+`noindex!` in their controller, emitting `robots: noindex, nofollow`.++## OG share-card images++`GET /og/:owner/:repo.png` → a 1200×630 PNG (repo name, owner, wrapped+description, primary-language dot, star count, siGit wordmark). `GET /og.png` is+the generic sitewide card and the default `og:image`.++- Rendered by `OgImageService` (SVG template → PNG via **libvips**; the prod+ image installs `libvips`, `librsvg2-2`, and `fonts-dejavu-core`).+- Cached by content hash (`Repository#og_version`: tip commit + name ++ description + stars). Served `public`, long max-age, with an `ETag` so repeat+ fetches are `304`s. Never generated on the hot path when cached.+- **Privacy:** private repos `404` here (no card, even for the owner) and are+ excluded from the sitemap. A logged-out crawler hitting a private repo URL+ gets the static `404` with zero metadata.+- **Fallback:** if rasterization is unavailable, the endpoint serves the static+ brand icon with a short TTL so it self-heals — it never 500s.++## robots.txt / sitemap.xml++- `public/robots.txt` allows content, disallows `/auth /settings /billing /new+ /api/ /*/raw/`, and points at the sitemap.+- `GET /sitemap.xml` (`SitemapsController`) lists marketing/legal pages, every+ public repo, and the profiles that own one. Private repos and+ private-only users are excluded. Cached 6h.++## Verify locally++```bash+# Meta tags are in the initial HTML (simulate a crawler):+curl -s -A "Twitterbot" http://localhost:3000/<owner>/<repo> | grep -iE 'og:|twitter:|canonical|application/ld'++# Private repo leaks nothing and 404s:+curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000/<owner>/<private-repo> # 404++# OG image (needs libvips locally; otherwise returns the static fallback PNG):+curl -sI http://localhost:3000/og/<owner>/<repo>.png # 200, Content-Type: image/png, ETag, Cache-Control: public+curl -s http://localhost:3000/robots.txt+curl -s http://localhost:3000/sitemap.xml | head++# Tests:+bin/rails test+```++External validators (run against a deployed public URL): X Card Validator,+Facebook Sharing Debugger, LinkedIn Post Inspector, Google Rich Results Test.++> Note: macOS dev hosts usually lack libvips, so the OG endpoint returns the+> static fallback locally — the card itself renders in CI/production. To preview+> the card design without libvips, dump the SVG+> (`OgImageService.send(:repository_svg, repo)`) and open it in a browser.
public/robots.txt
+1
index 48adc01..b6edf1a 100644--- a/public/robots.txt+++ b/public/robots.txt@@ -6,6 +6,7 @@ Allow: / Disallow: /auth Disallow: /settings Disallow: /billing+Disallow: /new Disallow: /api/ # Raw file endpoints serve bytes, not pages.
test/controllers/og_images_controller_test.rb
+54
new file mode 100644index 0000000..159b7af--- /dev/null+++ b/test/controllers/og_images_controller_test.rb@@ -0,0 +1,54 @@+# frozen_string_literal: true++require "test_helper"++# The OG endpoint must always answer crawlers with a PNG (a generated card where+# libvips is available, a static fallback otherwise), cache aggressively, and+# never render a card for a private repo.+class OgImagesControllerTest < ActionDispatch::IntegrationTest+ setup do+ @public = repositories(:public_code)+ @model = repositories(:public_model)+ @private = repositories(:private_code)+ end++ test "public repo OG image returns a cacheable PNG" do+ get "/og/#{@public.user.username}/#{@public.name}.png"+ assert_response :success+ assert_equal "image/png", response.media_type+ assert_includes response.headers["Cache-Control"], "public"+ assert response.headers["ETag"].present?, "expected an ETag for conditional GETs"+ end++ test "dotted repo names route correctly" do+ get "/og/#{@model.user.username}/#{@model.name}.png"+ assert_response :success+ assert_equal "image/png", response.media_type+ end++ test "second hit with matching ETag is served from cache as 304" do+ get "/og/#{@public.user.username}/#{@public.name}.png"+ etag = response.headers["ETag"]+ assert etag.present?++ get "/og/#{@public.user.username}/#{@public.name}.png",+ headers: { "If-None-Match" => etag }+ assert_response :not_modified+ end++ test "default sitewide card returns a PNG" do+ get "/og.png"+ assert_response :success+ assert_equal "image/png", response.media_type+ end++ test "private repo OG image is not rendered (404)" do+ get "/og/#{@private.user.username}/#{@private.name}.png"+ assert_response :not_found+ end++ test "unknown repo OG image is 404" do+ get "/og/alice/does-not-exist.png"+ assert_response :not_found+ end+end
test/fixtures/repositories.yml
+29
new file mode 100644index 0000000..d818cd4--- /dev/null+++ b/test/fixtures/repositories.yml@@ -0,0 +1,29 @@+public_code:+ user: alice+ name: widget+ description: A tiny widget library for building UIs fast.+ default_branch: main+ kind: code+ disk_path: /nonexistent/alice/widget.git+ is_private: false+ stars_count: 7++public_model:+ user: alice+ name: Qwen2.5-GGUF+ description: Quantized GGUF weights for fast local inference.+ default_branch: main+ kind: model+ disk_path: /nonexistent/alice/qwen.git+ is_private: false+ stars_count: 0++private_code:+ user: bob+ name: secret-internal-tool+ description: TOPSECRETDESCRIPTION should never leak to crawlers.+ default_branch: main+ kind: code+ disk_path: /nonexistent/bob/secret.git+ is_private: true+ stars_count: 3
new file mode 100644index 0000000..cdabc1d--- /dev/null+++ b/test/integration/repository_seo_test.rb@@ -0,0 +1,60 @@+# frozen_string_literal: true++require "test_helper"++# Verifies the server-rendered SEO/social meta tags for public vs private repos.+# Crawlers don't run JS, so everything asserted here must be in the initial HTML.+class RepositorySeoTest < ActionDispatch::IntegrationTest+ setup do+ @public = repositories(:public_code)+ @private = repositories(:private_code)+ end++ test "public repo page emits Open Graph, Twitter, and canonical tags server-side" do+ get "/#{@public.user.username}/#{@public.name}"+ assert_response :success++ assert_includes response.body, %(property="og:title" content="alice/widget")+ assert_includes response.body, %(property="og:site_name" content="siGit")+ assert_includes response.body, %(property="og:image" content="http://www.example.com/og/alice/widget.png")+ assert_includes response.body, %(property="og:image:width" content="1200")+ assert_includes response.body, %(property="og:image:height" content="630")+ assert_includes response.body, %(name="twitter:card" content="summary_large_image")+ assert_includes response.body, %(rel="canonical" href="http://www.example.com/alice/widget")+ assert_includes response.body, "A tiny widget library"+ end++ test "public repo page emits SoftwareSourceCode JSON-LD" do+ get "/#{@public.user.username}/#{@public.name}"+ assert_response :success+ assert_includes response.body, %("@type":"SoftwareSourceCode")+ assert_includes response.body, %("codeRepository":"http://www.example.com/alice/widget")+ end++ test "public repo page is indexable" do+ get "/#{@public.user.username}/#{@public.name}"+ assert_includes response.body, %(name="robots" content="index, follow")+ end++ test "private repo returns 404 and leaks no metadata to a logged-out crawler" do+ get "/#{@private.user.username}/#{@private.name}"+ assert_response :not_found+ refute_includes response.body, "TOPSECRETDESCRIPTION"+ refute_includes response.body, "secret-internal-tool"+ refute_includes response.body, "og/bob"+ end++ test "auth pages are marked noindex" do+ get "/auth"+ assert_response :success+ assert_includes response.body, %(name="robots" content="noindex, nofollow")+ end++ test "empty-description repo still gets a non-empty description tag" do+ @public.update_column(:description, nil)+ get "/#{@public.user.username}/#{@public.name}"+ assert_response :success+ # Falls back to the generated seo_description rather than an empty tag.+ assert_includes response.body, "Git hosting built for AI workflows"+ end+end
test/integration/sitemap_robots_test.rb
+35
new file mode 100644index 0000000..df4fd2e--- /dev/null+++ b/test/integration/sitemap_robots_test.rb@@ -0,0 +1,35 @@+# frozen_string_literal: true++require "test_helper"++# robots.txt and sitemap.xml must expose public content and exclude anything+# private or behind auth.+class SitemapRobotsTest < ActionDispatch::IntegrationTest+ test "sitemap lists public repos and their owners" do+ get "/sitemap.xml"+ assert_response :success+ assert_equal "application/xml", response.media_type++ assert_includes response.body, "http://www.example.com/alice/widget"+ assert_includes response.body, "http://www.example.com/alice/Qwen2.5-GGUF"+ assert_includes response.body, "http://www.example.com/alice"+ end++ test "sitemap excludes private repos and users who only have private repos" do+ get "/sitemap.xml"+ refute_includes response.body, "secret-internal-tool"+ refute_includes response.body, "http://www.example.com/bob"+ end++ test "robots.txt allows content and disallows auth/transactional routes" do+ get "/robots.txt"+ assert_response :success++ body = response.body+ assert_includes body, "Sitemap: https://sigit.si/sitemap.xml"+ assert_includes body, "Disallow: /settings"+ assert_includes body, "Disallow: /billing"+ assert_includes body, "Disallow: /auth"+ assert_includes body, "Disallow: /api/"+ end+end
test/models/repository_seo_test.rb
+35
new file mode 100644index 0000000..4eff185--- /dev/null+++ b/test/models/repository_seo_test.rb@@ -0,0 +1,35 @@+# frozen_string_literal: true++require "test_helper"++class RepositoryModelSeoTest < ActiveSupport::TestCase+ test "seo_description uses the description when present" do+ repo = repositories(:public_code)+ assert_equal "A tiny widget library for building UIs fast.", repo.seo_description+ end++ test "seo_description falls back for a code repo without a description" do+ repo = repositories(:public_code)+ repo.description = nil+ assert_includes repo.seo_description, "@alice"+ assert_includes repo.seo_description, "Git hosting built for AI workflows"+ end++ test "seo_description falls back for a model repo without a description" do+ repo = repositories(:public_model)+ repo.description = nil+ assert_includes repo.seo_description, "open-weights model"+ end++ test "og_version changes when share-card inputs change" do+ repo = repositories(:public_code)+ before = repo.og_version+ repo.stars_count = repo.stars_count + 1+ refute_equal before, repo.og_version+ end++ test "primary_language is nil for an uninitialized repo" do+ # Fixture disk_path does not exist on disk.+ assert_nil repositories(:public_code).primary_language+ end+end
test/services/og_image_service_test.rb
+44
new file mode 100644index 0000000..93a50fb--- /dev/null+++ b/test/services/og_image_service_test.rb@@ -0,0 +1,44 @@+# frozen_string_literal: true++require "test_helper"++class OgImageServiceTest < ActiveSupport::TestCase+ test "repository SVG includes the repo name, owner, and is well-formed" do+ repo = repositories(:public_code)+ svg = OgImageService.send(:repository_svg, repo)++ assert svg.start_with?("<svg")+ assert_includes svg, "@alice /"+ assert_includes svg, "widget"+ assert_includes svg, %(width="1200")+ assert_includes svg, %(height="630")+ end++ test "user-controlled text is XML-escaped to prevent SVG injection" do+ repo = repositories(:public_code)+ repo.name = %q{x"><script>alert(1)</script>}+ repo.description = "evil & <b>markup</b>"+ svg = OgImageService.send(:repository_svg, repo)++ refute_includes svg, "<script>"+ assert_includes svg, "<script>"+ assert_includes svg, "&"+ end++ test "default card is generated without a repository" do+ svg = OgImageService.send(:default_svg)+ assert svg.start_with?("<svg")+ assert_includes svg, "Git hosting for the AI era"+ end++ test "render raises a typed error when libvips/SVG support is unavailable" do+ # In CI/local without libvips this exercises the fallback path the+ # controller relies on; where libvips exists it returns PNG bytes instead.+ begin+ png = OgImageService.render_default+ assert png.start_with?("\x89PNG".b), "expected PNG magic bytes"+ rescue OgImageService::Error+ assert true # graceful, controller-catchable failure+ end+ end+end
test/test_helper.rb
+15
new file mode 100644index 0000000..9614c9f--- /dev/null+++ b/test/test_helper.rb@@ -0,0 +1,15 @@+# frozen_string_literal: true++ENV["RAILS_ENV"] ||= "test"+require_relative "../config/environment"+require "rails/test_help"++module ActiveSupport+ class TestCase+ # Run tests in parallel with specified workers+ parallelize(workers: :number_of_processors)++ # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.+ fixtures :all+ end+end