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