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