main
md 172 lines 7.39 KB
Rendered Raw
1 # Spec: Social link unfurls and SEO for public repos
2
3 Status: implemented (branch `feature/social-unfurls-seo`, merged to `development`).
4 Reverse-engineered from the shipped code so the behavior is documented and
5 testable. Scope is meta tags, Open Graph card images, and crawlability for
6 public repositories.
7
8 ## Problem
9
10 A pasted siGit repo link produced no preview card in Slack, X, LinkedIn, and
11 Discord, and public repos were not cleanly indexable. Those are the channels
12 where developer tools spread, so a shared link should render a preview card and
13 leave a backlink.
14
15 ## Goals
16
17 1. Every public repo page serves complete SEO and social meta tags in the
18 initial server HTML, because crawlers do not run JavaScript.
19 2. Each public repo has a generated 1200x630 preview image.
20 3. Public repos are crawlable and listed in the sitemap; private and
21 auth-gated pages are not, and leak nothing.
22
23 ## Non-goals
24
25 Profile pages, stars/forks/explore/trending UI, full-text search, and
26 analytics. This work is meta tags, card images, and crawlability only.
27
28 ## Rendering model
29
30 The app is server-rendered Rails (ERB + Hotwire), so meta tags and a crawlable
31 HTML body already appear in the initial response. No client-render workaround is
32 needed.
33
34 ## Routes
35
36 | Method | Path | Controller | Purpose |
37 |--------|------|------------|---------|
38 | GET | `/og.png` | `og_images#default` | Generic sitewide card (default `og:image`). |
39 | GET | `/og/:username/:repository.png` | `og_images#show` | Per-repo card. Dotted names allowed (`Qwen2.5-GGUF`), `format: false`. |
40 | GET | `/sitemap.xml` | `sitemaps#index` | Public repos plus their owners (pre-existing). |
41
42 Both `/og` routes are declared before the `/:username` matcher so they are not
43 read as profiles.
44
45 ## Behavior
46
47 ### Meta tags (all pages)
48
49 Driven by `SeoHelper` and the `shared/_seo` partial. Defaults degrade so any
50 page emits valid, non-empty tags. Pages override with `content_for`:
51 `:title`, `:description`, `:og_image`, `:og_image_alt`, `:og_type`, `:robots`,
52 `:structured_data`.
53
54 Emitted: `<title>`, `meta description`, `link canonical` (path only, no query or
55 fragment), Open Graph (`og:type`, `og:site_name` = `siGit`, `og:title`,
56 `og:description`, `og:url`, `og:image`, `og:image:type`, `og:image:width` =
57 1200, `og:image:height` = 630, `og:image:alt`, `og:locale`), Twitter
58 (`summary_large_image`, title, description, image, image:alt), and JSON-LD
59 (`Organization` + `WebSite` sitewide).
60
61 ### Public repo pages
62
63 `repositories#show`, `#model`, `#tree`, `#cicd`, and `blobs#show` render
64 `shared/_repository_social`, which sets the description, the per-repo
65 `og:image` (`/og/:owner/:repo.png`), and the image alt text. The two canonical
66 repo pages (`show`, `model`) also emit `SoftwareSourceCode` JSON-LD with name,
67 description, repo URL, author, dates, image, and `programmingLanguage` when a
68 primary language is detected.
69
70 `Repository#seo_description` returns the description when present, otherwise a
71 fallback that names the owner and the kind (code repo or open-weights model).
72 This guarantees non-empty social text.
73
74 ### OG card images
75
76 `OgImageService` builds an SVG from the brand template and rasterizes it to PNG
77 with libvips. Card content: repo name, `@owner`, wrapped description (up to
78 three lines), primary-language dot with color, star count when above zero, and
79 the siGit wordmark. The generic card carries the tagline only.
80
81 All user-controlled text is XML-escaped before going into the SVG.
82
83 Primary language is guessed from file extensions on the default branch
84 (`Repository#primary_language`), using a small Linguist-style color map. Model
85 repos that carry weight files surface the weight format instead of a code
86 language.
87
88 ### Caching and performance
89
90 `OgImagesController` answers conditional GETs with `stale?(etag:, public:)`. The
91 ETag is `OgImageService::TEMPLATE_VERSION` plus `Repository#og_version`, a hash
92 of repo id, name, description, star count, default-branch tip commit, and
93 `updated_at`. A repeat fetch with a matching `If-None-Match` returns 304. Bytes
94 are cached in `Rails.cache` keyed by the same version, so the SVG is rasterized
95 only on a cache miss, never on the hot path when a card exists. Responses set
96 `Cache-Control: public` with a long max-age.
97
98 The template version bumps invalidate every cached card. The content hash
99 invalidates a single repo's card when its content changes.
100
101 ### Fallback
102
103 If rasterization is unavailable (no libvips, no SVG or font support), the
104 endpoint logs a warning and serves the static brand icon
105 (`public/favicon/web-app-manifest-512x512.png`) with a short TTL, so a transient
106 failure self-heals and the endpoint never returns 500.
107
108 ### Privacy guards
109
110 - A private repo returns 404 on `/og/:owner/:repo.png` for everyone, including
111 the owner, so no card can leak.
112 - Visibility is decided server-side from the repo's `is_private` flag, not a
113 client signal. A logged-out request to a private repo page renders the static
114 404 with no metadata.
115 - Private repos and users who own only private repos are excluded from
116 `sitemap.xml`.
117 - Auth and transactional pages (`sessions`, `registrations`, `passwords`,
118 `confirmations`, `billing`, `users#settings`, `repositories#new`) call
119 `noindex!` in the controller, which makes `SeoHelper#meta_robots` emit
120 `noindex, nofollow`.
121
122 ### robots.txt
123
124 `public/robots.txt` allows content, disallows `/auth`, `/settings`, `/billing`,
125 `/new`, `/api/`, and `/*/raw/`, and points at `/sitemap.xml`.
126
127 ## Acceptance criteria
128
129 1. A public repo URL pasted into Slack, X, LinkedIn, or Discord shows a
130 `summary_large_image` card with the correct title, description, and image.
131 2. `view-source` on a public repo page shows the OG, Twitter, and canonical
132 tags in the initial HTML.
133 3. `/og/:owner/:repo.png` returns a 1200x630 PNG, is a 304 on the second hit
134 with a matching ETag, and has a fallback for missing metadata.
135 4. A private repo URL returns no preview, carries `noindex`, and is absent from
136 `sitemap.xml`.
137 5. `robots.txt` and `sitemap.xml` validate; public repos appear in the sitemap,
138 auth and non-content routes do not.
139
140 ## Production dependency
141
142 The runtime image installs `libvips`, `librsvg2-2`, and `fonts-dejavu-core` so
143 libvips can rasterize SVG text. macOS dev hosts usually lack libvips, so the
144 endpoint returns the static fallback locally; the card renders in CI and
145 production.
146
147 ## Tests
148
149 RSpec, the project framework.
150
151 - `spec/requests/repository_seo_spec.rb`: meta output for public vs private,
152 JSON-LD, indexable flag, empty-description fallback, noindex on auth pages.
153 - `spec/requests/og_images_spec.rb`: PNG content type, cache headers, 304 on
154 conditional GET, dotted names, private 404, unknown 404, default card.
155 - `spec/requests/sitemap_robots_spec.rb`: sitemap inclusion and exclusion,
156 robots directives.
157 - `spec/models/repository_seo_spec.rb`: `seo_description`, `og_version`,
158 `primary_language`.
159 - `spec/services/og_image_service_spec.rb`: SVG structure, XML-escaping, default
160 card, render-or-typed-error.
161
162 ## Key files
163
164 - `app/services/og_image_service.rb`, `app/controllers/og_images_controller.rb`
165 - `app/helpers/seo_helper.rb`, `app/views/shared/_seo.html.erb`,
166 `app/views/shared/_repository_social.html.erb`
167 - `app/models/repository.rb` (`seo_description`, `primary_language`,
168 `og_version`), `app/services/git_repository_service.rb` (`head_sha`,
169 `tree_filenames`)
170 - `app/controllers/application_controller.rb` (`noindex!`), `config/routes.rb`,
171 `public/robots.txt`, `Dockerfile`
172 - `docs/seo-and-social-unfurls.md` (how to verify)