feat(seo): thorough on-page SEO

Add a SeoHelper + shared/_seo partial that emit, for every page, a templated title, meta description, canonical URL, robots directive, full Open Graph and Twitter Card tags, and Organization + WebSite JSON-LD. Pages override the defaults with content_for. - Per-page titles and descriptions on home, code, about, contact, privacy, terms, models, and repositories. - Home adds SoftwareApplication structured data for siGit Code. - robots.txt allows crawling, blocks /auth, /settings, /billing, /api, and raw file endpoints, and points at the sitemap. - Dynamic /sitemap.xml listing static pages plus every public repository and its owner profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 27, 2026 at 23:30 UTC 0f10405fff5ceb5a57355c00404ae3de0e808fe4
15 files changed +191 -3
app/controllers/sitemaps_controller.rb new
+22
@@ -0,0 +1,22 @@
1 +# frozen_string_literal: true
2 +
3 +# XML sitemap for search engines. Public — fetched anonymously — and lists the
4 +# static marketing/legal pages plus every public repository and the profiles
5 +# that own them. Private repos (and the users with only private repos) are
6 +# excluded so nothing behind auth leaks into the index.
7 +class SitemapsController < ApplicationController
8 + # Cap the URL count well under the 50k / 50MB sitemap limit; newest content
9 + # first so the most relevant pages are always present.
10 + MAX_REPOSITORIES = 10_000
11 +
12 + def index
13 + @repositories = Repository.where(is_private: false)
14 + .includes(:user)
15 + .order(updated_at: :desc)
16 + .limit(MAX_REPOSITORIES)
17 + @profiles = @repositories.filter_map(&:user).uniq
18 +
19 + expires_in 6.hours, public: true
20 + respond_to(&:xml)
21 + end
22 +end
app/helpers/seo_helper.rb new
+83
@@ -0,0 +1,83 @@
1 +# frozen_string_literal: true
2 +
3 +# On-page SEO helpers. The layout renders `shared/_seo`, which reads these to
4 +# emit the title, description, canonical, Open Graph, Twitter Card, and JSON-LD
5 +# tags. Pages override the defaults with `content_for`:
6 +#
7 +# <% content_for :title, "Models" %>
8 +# <% content_for :description, "Browse open-weights models on siGit." %>
9 +# <% content_for :og_image, image_or_absolute_url %> # optional
10 +# <% content_for :robots, "noindex, follow" %> # optional
11 +#
12 +# Everything degrades to a sensible sitewide default, so a page that sets
13 +# nothing still gets valid, non-empty tags.
14 +module SeoHelper
15 + SITE_NAME = "siGit"
16 + TITLE_SUFFIX = "siGit Code & Deploy"
17 +
18 + DEFAULT_DESCRIPTION =
19 + "siGit is Git hosting for the AI era. Version your repositories, publish " \
20 + "open-weights models with full model cards and LFS-backed files, and hand " \
21 + "the keys to your AI coding agent over the Agent Client Protocol."
22 +
23 + # Square PWA icon used as the Open Graph / Twitter fallback image. A dedicated
24 + # 1200×630 share image would render larger in social previews; drop one at
25 + # /og-cover.png and point DEFAULT_OG_IMAGE_PATH at it to upgrade.
26 + DEFAULT_OG_IMAGE_PATH = "/favicon/web-app-manifest-512x512.png"
27 +
28 + # "<page> · siGit Code & Deploy", or just the suffix on the bare root.
29 + def page_full_title
30 + page = content_for(:title)
31 + page.present? ? "#{page} · #{TITLE_SUFFIX}" : TITLE_SUFFIX
32 + end
33 +
34 + def meta_description
35 + strip_tags(content_for(:description).presence || DEFAULT_DESCRIPTION).squish
36 + end
37 +
38 + # Canonical URL without query string or fragment, so tracking params and
39 + # pagination don't fork duplicate URLs in the index.
40 + def canonical_url
41 + "#{request.base_url}#{request.path}"
42 + end
43 +
44 + def og_image_url
45 + custom = content_for(:og_image)
46 + return custom if custom.present? && custom.start_with?("http")
47 +
48 + "#{request.base_url}#{custom.presence || DEFAULT_OG_IMAGE_PATH}"
49 + end
50 +
51 + def meta_robots
52 + content_for(:robots).presence || "index, follow"
53 + end
54 +
55 + def og_type
56 + content_for(:og_type).presence || "website"
57 + end
58 +
59 + # Organization + WebSite, emitted on every page. Establishes the site entity
60 + # for knowledge-panel / sitelinks eligibility.
61 + def organization_json_ld
62 + {
63 + "@context" => "https://schema.org",
64 + "@graph" => [
65 + {
66 + "@type" => "Organization",
67 + "@id" => "#{request.base_url}/#organization",
68 + "name" => SITE_NAME,
69 + "url" => request.base_url,
70 + "logo" => "#{request.base_url}#{DEFAULT_OG_IMAGE_PATH}",
71 + "description" => DEFAULT_DESCRIPTION
72 + },
73 + {
74 + "@type" => "WebSite",
75 + "@id" => "#{request.base_url}/#website",
76 + "name" => SITE_NAME,
77 + "url" => request.base_url,
78 + "publisher" => { "@id" => "#{request.base_url}/#organization" }
79 + }
80 + ]
81 + }
82 + end
83 +end
app/views/layouts/application.html.erb
+1 -2
@@ -5,8 +5,7 @@
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <meta name="color-scheme" content="dark">
7 <meta name="theme-color" content="#1a1d21">
8 - <meta name="description" content="siGit — quiet Git hosting">
9 - <title><%= content_for?(:title) ? "#{yield(:title)} · siGit Code & Deploy" : "siGit Code & Deploy" %></title>
8 + <%= render "shared/seo" %>
9 <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png">
10 <link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg">
11 <link rel="icon" type="image/png" sizes="96x96" href="/favicon/favicon-96x96.png">
app/views/models/index.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "Models" %>
2 +<% content_for :description, "Browse open-weights models on siGit — full model cards, LFS-backed files, and ready-to-pull weights." %>
3
4 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-8">
5
app/views/pages/about.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "About" %>
2 +<% content_for :description, "About siGit — Git hosting for the AI era: repositories, open-weights model hosting, and AI coding agents over the Agent Client Protocol." %>
3
4 <div class="max-w-3xl mx-auto px-4 sm:px-6 py-16 sm:py-20">
5 <div class="text-center mb-12">
app/views/pages/code.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "siGit Code" %>
2 +<% content_for :description, "siGit Code is a local-first AI coding agent. Run LLM inference on-device or against a hosted endpoint, and drive it from your editor over the Agent Client Protocol." %>
3
4 <div class="min-h-screen bg-surface-900">
5 <%# ── Hero ── %>
app/views/pages/contact.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "Contact" %>
2 +<% content_for :description, "Get in touch with siGit — report a bug, request a feature, or tell us what broke." %>
3
4 <div class="max-w-3xl mx-auto px-4 sm:px-6 py-16 sm:py-20">
5 <div class="text-center mb-12">
app/views/pages/home.html.erb
+14 -1
@@ -1,4 +1,17 @@
1 -<% content_for :title, "Welcome" %>
1 +<% content_for :title, "Git hosting for the AI era" %>
2 +<% content_for :description, "siGit hosts your Git repositories and open-weights models with full model cards and LFS-backed files, and hands the keys to your AI coding agent over the Agent Client Protocol." %>
3 +<% content_for :structured_data do %>
4 + <%= tag.script({
5 + "@context" => "https://schema.org",
6 + "@type" => "SoftwareApplication",
7 + "name" => "siGit Code",
8 + "applicationCategory" => "DeveloperApplication",
9 + "operatingSystem" => "macOS, Linux, Windows",
10 + "url" => code_url,
11 + "description" => "Local-first AI coding agent that runs on-device or against a hosted endpoint and speaks the Agent Client Protocol for editor integration.",
12 + "offers" => { "@type" => "Offer", "price" => "0", "priceCurrency" => "USD" }
13 + }.to_json.html_safe, type: "application/ld+json") %>
14 +<% end %>
15
16 <div class="min-h-screen bg-surface-900">
17 <section class="border-b border-surface-600 bg-surface-800">
app/views/pages/privacy.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "Privacy Policy" %>
2 +<% content_for :description, "How siGit collects, uses, and protects your data. Read the siGit privacy policy." %>
3
4 <div class="max-w-3xl mx-auto px-4 sm:px-6 py-16 sm:py-20">
5 <div class="text-center mb-12">
app/views/pages/terms.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "Terms of Use" %>
2 +<% content_for :description, "The terms that govern your use of siGit Code & Deploy." %>
3
4 <div class="max-w-3xl mx-auto px-4 sm:px-6 py-16 sm:py-20">
5 <div class="text-center mb-12">
app/views/repositories/explore.html.erb
+1
@@ -1,4 +1,5 @@
1 <% content_for :title, "Repositories" %>
2 +<% content_for :description, "Explore public Git repositories hosted on siGit." %>
3
4 <div class="max-w-6xl mx-auto px-4 sm:px-6 py-8">
5
app/views/shared/_seo.html.erb new
+25
@@ -0,0 +1,25 @@
1 +<%# On-page SEO tags. Driven by SeoHelper + per-page content_for overrides. %>
2 +<title><%= page_full_title %></title>
3 +<meta name="description" content="<%= meta_description %>">
4 +<meta name="robots" content="<%= meta_robots %>">
5 +<link rel="canonical" href="<%= canonical_url %>">
6 +
7 +<%# ── Open Graph ── %>
8 +<meta property="og:type" content="<%= og_type %>">
9 +<meta property="og:site_name" content="<%= SeoHelper::SITE_NAME %>">
10 +<meta property="og:title" content="<%= content_for(:title).presence || SeoHelper::TITLE_SUFFIX %>">
11 +<meta property="og:description" content="<%= meta_description %>">
12 +<meta property="og:url" content="<%= canonical_url %>">
13 +<meta property="og:image" content="<%= og_image_url %>">
14 +<meta property="og:image:alt" content="<%= SeoHelper::SITE_NAME %> — Git hosting for the AI era">
15 +<meta property="og:locale" content="en_US">
16 +
17 +<%# ── Twitter Card ── %>
18 +<meta name="twitter:card" content="summary_large_image">
19 +<meta name="twitter:title" content="<%= content_for(:title).presence || SeoHelper::TITLE_SUFFIX %>">
20 +<meta name="twitter:description" content="<%= meta_description %>">
21 +<meta name="twitter:image" content="<%= og_image_url %>">
22 +
23 +<%# ── Structured data ── %>
24 +<%= tag.script organization_json_ld.to_json.html_safe, type: "application/ld+json" %>
25 +<%= yield :structured_data %>
app/views/sitemaps/index.xml.erb new
+23
@@ -0,0 +1,23 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3 + <% [[root_url, "1.0"], [code_url, "0.9"], [models_url, "0.8"], [repos_url, "0.8"],
4 + [about_url, "0.5"], [contact_url, "0.3"], [privacy_url, "0.2"], [terms_url, "0.2"]].each do |loc, priority| %>
5 + <url>
6 + <loc><%= loc %></loc>
7 + <priority><%= priority %></priority>
8 + </url>
9 + <% end %>
10 + <% @repositories.each do |repository| %>
11 + <url>
12 + <loc><%= repository_url(repository.user.username, repository.name) %></loc>
13 + <lastmod><%= repository.updated_at.utc.iso8601 %></lastmod>
14 + <priority>0.6</priority>
15 + </url>
16 + <% end %>
17 + <% @profiles.each do |user| %>
18 + <url>
19 + <loc><%= user_profile_url(user.username) %></loc>
20 + <priority>0.4</priority>
21 + </url>
22 + <% end %>
23 +</urlset>
config/routes.rb
+3
@@ -9,6 +9,9 @@ Rails.application.routes.draw do
9 get "/privacy", to: "pages#privacy", as: :privacy
10 get "/terms", to: "pages#terms", as: :terms
11
12 + # XML sitemap for search engines (referenced from public/robots.txt).
13 + get "/sitemap.xml", to: "sitemaps#index", defaults: { format: "xml" }, as: :sitemap
14 +
15 # Dev panel — only mounted in development
16 if Rails.env.development?
17 namespace :dev do
public/robots.txt
+13
@@ -1 +1,14 @@
1 # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 +User-agent: *
3 +Allow: /
4 +
5 +# Private and transactional areas — no SEO value, keep them out of the index.
6 +Disallow: /auth
7 +Disallow: /settings
8 +Disallow: /billing
9 +Disallow: /api/
10 +
11 +# Raw file endpoints serve bytes, not pages.
12 +Disallow: /*/raw/
13 +
14 +Sitemap: https://sigit.si/sitemap.xml