| 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. |