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
- Every public repo page serves complete SEO and social meta tags in the initial server HTML, because crawlers do not run JavaScript.
- Each public repo has a generated 1200x630 preview image.
- 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.pngfor everyone, including the owner, so no card can leak. - Visibility is decided server-side from the repo's
is_privateflag, 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) callnoindex!in the controller, which makesSeoHelper#meta_robotsemitnoindex, nofollow.
robots.txt
public/robots.txt allows content, disallows /auth, /settings, /billing,
/new, /api/, and /*/raw/, and points at /sitemap.xml.
Acceptance criteria
- A public repo URL pasted into Slack, X, LinkedIn, or Discord shows a
summary_large_imagecard with the correct title, description, and image. view-sourceon a public repo page shows the OG, Twitter, and canonical tags in the initial HTML./og/:owner/:repo.pngreturns a 1200x630 PNG, is a 304 on the second hit with a matching ETag, and has a fallback for missing metadata.- A private repo URL returns no preview, carries
noindex, and is absent fromsitemap.xml. robots.txtandsitemap.xmlvalidate; 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.rbapp/helpers/seo_helper.rb,app/views/shared/_seo.html.erb,app/views/shared/_repository_social.html.erbapp/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,Dockerfiledocs/seo-and-social-unfurls.md(how to verify)