main
md 7.39 KB

Spec: Social link unfurls and SEO for public repos

Status: implemented (branch feature/social-unfurls-seo, merged to development). Reverse-engineered from the shipped code so the behavior is documented and testable. Scope is meta tags, Open Graph card images, and crawlability for public repositories.

Problem

A pasted siGit repo link produced no preview card in Slack, X, LinkedIn, and Discord, and public repos were not cleanly indexable. Those are the channels where developer tools spread, so a shared link should render a preview card and leave a backlink.

Goals

  1. Every public repo page serves complete SEO and social meta tags in the initial server HTML, because crawlers do not run JavaScript.
  2. Each public repo has a generated 1200x630 preview image.
  3. Public repos are crawlable and listed in the sitemap; private and auth-gated pages are not, and leak nothing.

Non-goals

Profile pages, stars/forks/explore/trending UI, full-text search, and analytics. This work is meta tags, card images, and crawlability only.

Rendering model

The app is server-rendered Rails (ERB + Hotwire), so meta tags and a crawlable HTML body already appear in the initial response. No client-render workaround is needed.

Routes

Method Path Controller Purpose
GET /og.png og_images#default Generic sitewide card (default og:image).
GET /og/:username/:repository.png og_images#show Per-repo card. Dotted names allowed (Qwen2.5-GGUF), format: false.
GET /sitemap.xml sitemaps#index Public repos plus their owners (pre-existing).

Both /og routes are declared before the /:username matcher so they are not read as profiles.

Behavior

Meta tags (all pages)

Driven by SeoHelper and the shared/_seo partial. Defaults degrade so any page emits valid, non-empty tags. Pages override with content_for: :title, :description, :og_image, :og_image_alt, :og_type, :robots, :structured_data.

Emitted: <title>, meta description, link canonical (path only, no query or fragment), Open Graph (og:type, og:site_name = siGit, og:title, og:description, og:url, og:image, og:image:type, og:image:width = 1200, og:image:height = 630, og:image:alt, og:locale), Twitter (summary_large_image, title, description, image, image:alt), and JSON-LD (Organization + WebSite sitewide).

Public repo pages

repositories#show, #model, #tree, #cicd, and blobs#show render shared/_repository_social, which sets the description, the per-repo og:image (/og/:owner/:repo.png), and the image alt text. The two canonical repo pages (show, model) also emit SoftwareSourceCode JSON-LD with name, description, repo URL, author, dates, image, and programmingLanguage when a primary language is detected.

Repository#seo_description returns the description when present, otherwise a fallback that names the owner and the kind (code repo or open-weights model). This guarantees non-empty social text.

OG card images

OgImageService builds an SVG from the brand template and rasterizes it to PNG with libvips. Card content: repo name, @owner, wrapped description (up to three lines), primary-language dot with color, star count when above zero, and the siGit wordmark. The generic card carries the tagline only.

All user-controlled text is XML-escaped before going into the SVG.

Primary language is guessed from file extensions on the default branch (Repository#primary_language), using a small Linguist-style color map. Model repos that carry weight files surface the weight format instead of a code language.

Caching and performance

OgImagesController answers conditional GETs with stale?(etag:, public:). The ETag is OgImageService::TEMPLATE_VERSION plus Repository#og_version, a hash of repo id, name, description, star count, default-branch tip commit, and updated_at. A repeat fetch with a matching If-None-Match returns 304. Bytes are cached in Rails.cache keyed by the same version, so the SVG is rasterized only on a cache miss, never on the hot path when a card exists. Responses set Cache-Control: public with a long max-age.

The template version bumps invalidate every cached card. The content hash invalidates a single repo's card when its content changes.

Fallback

If rasterization is unavailable (no libvips, no SVG or font support), the endpoint logs a warning and serves the static brand icon (public/favicon/web-app-manifest-512x512.png) with a short TTL, so a transient failure self-heals and the endpoint never returns 500.

Privacy guards

  • A private repo returns 404 on /og/:owner/:repo.png for everyone, including the owner, so no card can leak.
  • Visibility is decided server-side from the repo's is_private flag, not a client signal. A logged-out request to a private repo page renders the static 404 with no metadata.
  • Private repos and users who own only private repos are excluded from sitemap.xml.
  • Auth and transactional pages (sessions, registrations, passwords, confirmations, billing, users#settings, repositories#new) call noindex! in the controller, which makes SeoHelper#meta_robots emit noindex, nofollow.

robots.txt

public/robots.txt allows content, disallows /auth, /settings, /billing, /new, /api/, and /*/raw/, and points at /sitemap.xml.

Acceptance criteria

  1. A public repo URL pasted into Slack, X, LinkedIn, or Discord shows a summary_large_image card with the correct title, description, and image.
  2. view-source on a public repo page shows the OG, Twitter, and canonical tags in the initial HTML.
  3. /og/:owner/:repo.png returns a 1200x630 PNG, is a 304 on the second hit with a matching ETag, and has a fallback for missing metadata.
  4. A private repo URL returns no preview, carries noindex, and is absent from sitemap.xml.
  5. robots.txt and sitemap.xml validate; public repos appear in the sitemap, auth and non-content routes do not.

Production dependency

The runtime image installs libvips, librsvg2-2, and fonts-dejavu-core so libvips can rasterize SVG text. macOS dev hosts usually lack libvips, so the endpoint returns the static fallback locally; the card renders in CI and production.

Tests

RSpec, the project framework.

  • spec/requests/repository_seo_spec.rb: meta output for public vs private, JSON-LD, indexable flag, empty-description fallback, noindex on auth pages.
  • spec/requests/og_images_spec.rb: PNG content type, cache headers, 304 on conditional GET, dotted names, private 404, unknown 404, default card.
  • spec/requests/sitemap_robots_spec.rb: sitemap inclusion and exclusion, robots directives.
  • spec/models/repository_seo_spec.rb: seo_description, og_version, primary_language.
  • spec/services/og_image_service_spec.rb: SVG structure, XML-escaping, default card, render-or-typed-error.

Key files

  • app/services/og_image_service.rb, app/controllers/og_images_controller.rb
  • app/helpers/seo_helper.rb, app/views/shared/_seo.html.erb, app/views/shared/_repository_social.html.erb
  • app/models/repository.rb (seo_description, primary_language, og_version), app/services/git_repository_service.rb (head_sha, tree_filenames)
  • app/controllers/application_controller.rb (noindex!), config/routes.rb, public/robots.txt, Dockerfile
  • docs/seo-and-social-unfurls.md (how to verify)