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