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
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 100644 index 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
app/controllers/repositories_controller.rb
+1
index 973b2c6..e04d44c 100644 --- a/app/controllers/repositories_controller.rb +++ b/app/controllers/repositories_controller.rb @@ -1,5 +1,6 @@ class RepositoriesController < ApplicationController before_action :require_sign_in!, only: [ :new, :create ] + before_action :noindex!, only: [ :new, :create ] before_action :load_owner, only: [ :show, :tree, :cicd ] before_action :load_repository, only: [ :show, :tree, :cicd ] before_action :ensure_can_read!, only: [ :show, :tree, :cicd ]
app/controllers/sessions_controller.rb
+1
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 100644 index 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
app/views/blobs/show.html.erb
+1
index 356848e..9bde560 100644 --- a/app/views/blobs/show.html.erb +++ b/app/views/blobs/show.html.erb @@ -1,4 +1,5 @@ <% content_for :title, "#{@filename} · #{@repository.full_name}" %> +<%= render "shared/repository_social", repository: @repository %> <div class="border-b border-surface-600 bg-surface-800"> <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
app/views/repositories/cicd.html.erb
+1
index 24fde7c..d48f302 100644 --- a/app/views/repositories/cicd.html.erb +++ b/app/views/repositories/cicd.html.erb @@ -1,4 +1,5 @@ <% content_for :title, "CI/CD · #{@repository.full_name}" %> +<%= render "shared/repository_social", repository: @repository %> <div class="border-b border-surface-600 bg-surface-800"> <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
app/views/repositories/model.html.erb
+2
index eea3c2f..df43cef 100644 --- a/app/views/repositories/model.html.erb +++ b/app/views/repositories/model.html.erb @@ -1,4 +1,6 @@ <% content_for :title, @repository.full_name %> +<%= render "shared/repository_social", repository: @repository %> +<% content_for :structured_data, repository_structured_data(@repository) %> <% card = @card || @repository.model_card %> <div data-controller="tabs" data-tabs-active-value="<%= params[:tab] || 'card' %>">
app/views/repositories/show.html.erb
+2
index 28e8340..9ecf590 100644 --- a/app/views/repositories/show.html.erb +++ b/app/views/repositories/show.html.erb @@ -1,4 +1,6 @@ <% content_for :title, @repository.full_name %> +<%= render "shared/repository_social", repository: @repository %> +<% content_for :structured_data, repository_structured_data(@repository) %> <div class="border-b border-surface-600 bg-surface-800"> <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
app/views/repositories/tree.html.erb
+1
index 5f81fd8..9fdb72b 100644 --- a/app/views/repositories/tree.html.erb +++ b/app/views/repositories/tree.html.erb @@ -1,4 +1,5 @@ <% content_for :title, "#{@current_path} · #{@repository.full_name}" %> +<%= render "shared/repository_social", repository: @repository %> <div class="border-b border-surface-600 bg-surface-800"> <div class="max-w-6xl mx-auto px-4 sm:px-6 pt-6 pb-0">
app/views/shared/_repository_social.html.erb
+6
new file mode 100644 index 0000000..7f83466 --- /dev/null +++ b/app/views/shared/_repository_social.html.erb @@ -0,0 +1,6 @@ +<%# Per-repo Open Graph / Twitter overrides. Only ever rendered for public + repositories (private repos 404 before any view renders), so it is safe to + expose name/description/owner here. %> +<% content_for :description, repository.seo_description %> +<% content_for :og_image, repository_og_image_path(repository) %> +<% content_for :og_image_alt, "#{repository.full_name} — #{repository.model? ? "model" : "repository"} on siGit" %>
app/views/shared/_seo.html.erb
+5 -1
index bec4a1e..cf2b671 100644 --- a/app/views/shared/_seo.html.erb +++ b/app/views/shared/_seo.html.erb @@ -11,7 +11,10 @@ <meta property="og:description" content="<%= meta_description %>"> <meta property="og:url" content="<%= canonical_url %>"> <meta property="og:image" content="<%= og_image_url %>"> -<meta property="og:image:alt" content="<%= SeoHelper::SITE_NAME %> — Git hosting for the AI era"> +<meta property="og:image:type" content="image/png"> +<meta property="og:image:width" content="<%= SeoHelper::OG_IMAGE_WIDTH %>"> +<meta property="og:image:height" content="<%= SeoHelper::OG_IMAGE_HEIGHT %>"> +<meta property="og:image:alt" content="<%= content_for(:og_image_alt).presence || "#{SeoHelper::SITE_NAME} — Git hosting for the AI era" %>"> <meta property="og:locale" content="en_US"> <%# ── Twitter Card ── %> @@ -19,6 +22,7 @@ <meta name="twitter:title" content="<%= content_for(:title).presence || SeoHelper::TITLE_SUFFIX %>"> <meta name="twitter:description" content="<%= meta_description %>"> <meta name="twitter:image" content="<%= og_image_url %>"> +<meta name="twitter:image:alt" content="<%= content_for(:og_image_alt).presence || "#{SeoHelper::SITE_NAME} — Git hosting for the AI era" %>"> <%# ── Structured data ── %> <%= tag.script organization_json_ld.to_json.html_safe, type: "application/ld+json" %>
config/routes.rb
+8
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 100644 index 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 100644 index 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 100644 index 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
test/fixtures/users.yml
+11
new file mode 100644 index 0000000..1863e79 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,11 @@ +alice: + username: alice + email: alice@example.com + smbcloud_id: 1001 + display_name: Alice + +bob: + username: bob + email: bob@example.com + smbcloud_id: 1002 + display_name: Bob
test/integration/repository_seo_test.rb
+60
new file mode 100644 index 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 100644 index 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 100644 index 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 100644 index 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, "&lt;script&gt;" + assert_includes svg, "&amp;" + 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 100644 index 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