tenseleyflow/shithub / 5197fee

Browse files

Add SEO foundation pages and crawler metadata

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5197fee0710379fbfc2c20219f20e276e3ad8d41
Parents
c519013
Tree
c837843

15 changed files

StatusFile+-
M docs/internal/index.md 2 0
A docs/internal/seo.md 25 0
M internal/web/handlers/handlers.go 10 1
M internal/web/handlers/handlers_test.go 22 1
M internal/web/handlers/hello.go 24 15
A internal/web/handlers/seo.go 143 0
A internal/web/handlers/seo_test.go 29 0
M internal/web/handlers/testfixtures_test.go 4 1
M internal/web/render/render.go 79 7
M internal/web/render/render_test.go 60 0
M internal/web/server.go 1 0
M internal/web/static/css/shithub.css 82 0
M internal/web/templates/_footer.html 3 3
M internal/web/templates/_layout.html 12 4
A internal/web/templates/about.html 64 0
docs/internal/index.mdmodified
@@ -60,6 +60,8 @@ site.
6060
 - [orgs.md](./orgs.md), [teams.md](./teams.md)
6161
 - [notifications.md](./notifications.md)
6262
 - [search.md](./search.md), [markdown.md](./markdown.md)
63
+- [seo.md](./seo.md) — crawler endpoints, metadata, sitemap, and
64
+  public positioning.
6365
 
6466
 ## Operations
6567
 
docs/internal/seo.mdadded
@@ -0,0 +1,25 @@
1
+# SEO and crawler surfaces
2
+
3
+shithub exposes a small, honest crawler surface for the hosted site and
4
+self-hosted instances:
5
+
6
+- `GET /robots.txt` allows public pages and points crawlers at
7
+  `/sitemap.xml`.
8
+- `GET /sitemap.xml` lists stable public marketing/discovery pages:
9
+  `/`, `/about`, `/explore`, and `/trending`.
10
+- The shared HTML layout emits a page description, canonical URL,
11
+  Open Graph metadata, Twitter card metadata, and trusted JSON-LD when
12
+  handlers provide those fields. Missing fields are optional for typed
13
+  page-data structs.
14
+- `/about` is the durable positioning page. It follows the README,
15
+  SECURITY, CONTRIBUTING, and CODE_OF_CONDUCT posture: GitHub is good
16
+  software, shithub exists because users should be able to host code
17
+  without AI training concerns, and the community is AGPL, security-aware,
18
+  and civil.
19
+
20
+Use `auth.base_url` as the public origin in production. It drives the
21
+canonical links generated by crawler endpoints. If it is empty in tests
22
+or local dev, handlers fall back to the request host.
23
+
24
+Do not add generated search result pages, settings pages, API routes,
25
+admin pages, or smart-HTTP `.git` endpoints to the sitemap.
internal/web/handlers/handlers.gomodified
@@ -32,6 +32,10 @@ type Deps struct {
3232
 	LogoSVG      string
3333
 	SessionStore session.Store
3434
 	Pool         *pgxpool.Pool
35
+	// BaseURL is the public scheme+host for canonical crawler URLs
36
+	// (for example https://shithub.sh). Empty falls back to the request
37
+	// host, which keeps tests and local dev working.
38
+	BaseURL string
3539
 	// CookieSecure is the Secure flag for session-related cookies
3640
 	// (currently the CSRF cookie). Mirrors session.Config.Secure
3741
 	// from the loaded config so the CSRF cookie matches the
@@ -213,6 +217,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
213217
 		r.Use(middleware.Compress)
214218
 		r.Use(middleware.Timeout(30 * time.Second))
215219
 		r.Handle("/static/*", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
220
+		crawlers := crawlerHandler{baseURL: deps.BaseURL}
221
+		r.Get("/robots.txt", crawlers.serveRobots)
222
+		r.Get("/sitemap.xml", crawlers.serveSitemap)
216223
 		// S17: Chroma highlight CSS is generated at runtime from the
217224
 		// theme; serve under /static/css/chroma.css so the layout can
218225
 		// link it without a build step.
@@ -255,7 +262,9 @@ func RegisterChi(r *chi.Mux, deps Deps) (*chi.Mux, middleware.PanicHandler, http
255262
 		r.Use(middleware.Compress)
256263
 		r.Use(middleware.Timeout(30 * time.Second))
257264
 		r.Use(csrf)
258
-		r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, logger: deps.Logger}.ServeHTTP)
265
+		marketing := marketingHandler{render: rr, baseURL: deps.BaseURL, logger: deps.Logger}
266
+		r.Get("/", helloHandler{render: rr, logoSVG: deps.LogoSVG, baseURL: deps.BaseURL, logger: deps.Logger}.ServeHTTP)
267
+		r.Get("/about", marketing.serveAbout)
259268
 		r.Get("/explore", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeExplore)
260269
 		r.Get("/trending", exploreHandler{render: rr, logger: deps.Logger, pool: deps.Pool}.ServeTrending)
261270
 		globalNavH := globalNavHandler{render: rr, logger: deps.Logger, pool: deps.Pool}
internal/web/handlers/handlers_test.gomodified
@@ -36,9 +36,30 @@ func TestHandlers(t *testing.T) {
3636
 			name:        "hello page",
3737
 			path:        "/",
3838
 			wantStatus:  http.StatusOK,
39
-			wantBodyAny: []string{"shithub", "GitHub. Open source. Without Copilot.", "Sprint 00"},
39
+			wantBodyAny: []string{"shithub", "GitHub. Open source. Without Copilot.", "Sprint 00", `<meta name="description"`, `<link rel="canonical"`},
4040
 			wantHeader:  map[string]string{"Content-Type": "text/html; charset=utf-8"},
4141
 		},
42
+		{
43
+			name:        "about page",
44
+			path:        "/about",
45
+			wantStatus:  http.StatusOK,
46
+			wantBodyAny: []string{"No hard feelings to GitHub", "AI training on my code", `<meta name="description"`},
47
+			wantHeader:  map[string]string{"Content-Type": "text/html; charset=utf-8"},
48
+		},
49
+		{
50
+			name:        "robots",
51
+			path:        "/robots.txt",
52
+			wantStatus:  http.StatusOK,
53
+			wantBodyAny: []string{"User-agent: *", "Allow: /", "Disallow: /admin", "Sitemap: http://example.com/sitemap.xml"},
54
+			wantHeader:  map[string]string{"Content-Type": "text/plain; charset=utf-8"},
55
+		},
56
+		{
57
+			name:        "sitemap",
58
+			path:        "/sitemap.xml",
59
+			wantStatus:  http.StatusOK,
60
+			wantBodyAny: []string{`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`, "<loc>http://example.com/</loc>", "<loc>http://example.com/about</loc>"},
61
+			wantHeader:  map[string]string{"Content-Type": "application/xml; charset=utf-8"},
62
+		},
4263
 		{
4364
 			name:        "healthz",
4465
 			path:        "/healthz",
internal/web/handlers/hello.gomodified
@@ -15,6 +15,7 @@ import (
1515
 type helloHandler struct {
1616
 	render  *render.Renderer
1717
 	logoSVG string
18
+	baseURL string
1819
 	logger  *slog.Logger
1920
 }
2021
 
@@ -25,17 +26,19 @@ type helloData struct {
2526
 	BuiltAt string
2627
 	LogoSVG template.HTML
2728
 	// Viewer + CSRFToken mirror the fields _nav.html branches on. Typed
28
-	// page-data structs must populate them explicitly — the renderer
29
+	// page-data structs must populate them explicitly - the renderer
2930
 	// only auto-injects for map[string]any data.
3031
 	Viewer    middleware.CurrentUser
3132
 	CSRFToken string
32
-	// OG* are referenced by the shared _layout.html (S09). The fields
33
-	// must exist on every typed page-data struct that goes through the
34
-	// layout — html/template evaluates `{{ if .X }}` even on nil-checks
35
-	// and errors when X is missing.
36
-	OGTitle       string
37
-	OGDescription string
38
-	OGImage       string
33
+	// SEO/social fields are read by optional layout helpers, so typed
34
+	// page-data structs may provide only the fields they actually need.
35
+	MetaDescription string
36
+	CanonicalURL    string
37
+	OGTitle         string
38
+	OGDescription   string
39
+	OGImage         string
40
+	OGType          string
41
+	StructuredData  template.JS
3942
 	// GlobalSearchQuery is referenced by _nav.html's search input to
4043
 	// preserve the query when re-rendering after a search. Hello has
4144
 	// no query of its own, but the field must exist or template
@@ -51,13 +54,19 @@ type helloData struct {
5154
 func (h helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
5255
 	viewer := middleware.CurrentUserFromContext(r.Context())
5356
 	data := helloData{
54
-		Title:     "Welcome",
55
-		Version:   version.Version,
56
-		Commit:    version.Commit,
57
-		BuiltAt:   version.BuiltAt,
58
-		LogoSVG:   template.HTML(h.logoSVG), // #nosec G203 — embedded server-owned asset
59
-		Viewer:    viewer,
60
-		CSRFToken: middleware.CSRFTokenForRequest(r),
57
+		Title:           "Welcome",
58
+		Version:         version.Version,
59
+		Commit:          version.Commit,
60
+		BuiltAt:         version.BuiltAt,
61
+		LogoSVG:         template.HTML(h.logoSVG), // #nosec G203 - embedded server-owned asset
62
+		Viewer:          viewer,
63
+		CSRFToken:       middleware.CSRFTokenForRequest(r),
64
+		MetaDescription: defaultMetaDescription,
65
+		CanonicalURL:    canonicalURL(h.baseURL, r, "/"),
66
+		OGTitle:         "shithub: GitHub-style git hosting without Copilot",
67
+		OGDescription:   "A self-hostable, AGPL GitHub alternative with familiar repositories, pull requests, issues, organizations, code search, and Actions-style CI.",
68
+		OGType:          "website",
69
+		StructuredData:  organizationStructuredData(publicBaseURL(h.baseURL, r)),
6170
 	}
6271
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
6372
 	if err := h.render.RenderPage(w, r, "hello", data); err != nil {
internal/web/handlers/seo.goadded
@@ -0,0 +1,143 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"encoding/json"
7
+	"encoding/xml"
8
+	"fmt"
9
+	"html/template"
10
+	"log/slog"
11
+	"net/http"
12
+	"net/url"
13
+	"strings"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
16
+	"github.com/tenseleyFlow/shithub/internal/web/render"
17
+)
18
+
19
+const defaultMetaDescription = "shithub is an AGPL self-hosted GitHub alternative: Git repositories, pull requests, issues, Actions-style CI, organizations, and code search without Copilot."
20
+
21
+type marketingHandler struct {
22
+	render  *render.Renderer
23
+	baseURL string
24
+	logger  *slog.Logger
25
+}
26
+
27
+type crawlerHandler struct {
28
+	baseURL string
29
+}
30
+
31
+var sitemapPaths = []string{
32
+	"/",
33
+	"/about",
34
+	"/explore",
35
+	"/trending",
36
+}
37
+
38
+func (h marketingHandler) serveAbout(w http.ResponseWriter, r *http.Request) {
39
+	data := map[string]any{
40
+		"Title":             "GitHub alternative for self-hosted Git teams",
41
+		"MetaDescription":   "shithub is a self-hosted git forge and GitHub alternative for teams that want familiar pull requests, issues, Actions-style CI, and open-source control without Copilot.",
42
+		"CanonicalURL":      canonicalURL(h.baseURL, r, "/about"),
43
+		"OGTitle":           "shithub: self-hosted GitHub alternative",
44
+		"OGDescription":     "An AGPL git forge with GitHub-style repositories, pull requests, issues, organizations, code search, and Actions-style CI without Copilot.",
45
+		"StructuredData":    organizationStructuredData(publicBaseURL(h.baseURL, r)),
46
+		"GlobalSearchQuery": "",
47
+		"Viewer":            middleware.CurrentUserFromContext(r.Context()),
48
+		"CSRFToken":         middleware.CSRFTokenForRequest(r),
49
+		"Repo":              nil,
50
+		"Org":               nil,
51
+	}
52
+	if err := h.render.RenderPage(w, r, "about", data); err != nil {
53
+		h.logger.Error("render about", "error", err)
54
+		http.Error(w, "internal server error", http.StatusInternalServerError)
55
+	}
56
+}
57
+
58
+func (h crawlerHandler) serveRobots(w http.ResponseWriter, r *http.Request) {
59
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
60
+	w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
61
+
62
+	sitemap := canonicalURL(h.baseURL, r, "/sitemap.xml")
63
+	fmt.Fprintln(w, "User-agent: *")
64
+	fmt.Fprintln(w, "Allow: /")
65
+	fmt.Fprintln(w, "Disallow: /admin")
66
+	fmt.Fprintln(w, "Disallow: /api/")
67
+	fmt.Fprintln(w, "Disallow: /internal/")
68
+	fmt.Fprintln(w, "Disallow: /notifications")
69
+	fmt.Fprintln(w, "Disallow: /search")
70
+	fmt.Fprintln(w, "Disallow: /settings")
71
+	fmt.Fprintln(w, "Disallow: /*.git/")
72
+	if sitemap != "" {
73
+		fmt.Fprintln(w, "Sitemap: "+sitemap) // #nosec G705 -- text/plain robots body; request-host fallback rejects control characters.
74
+	}
75
+}
76
+
77
+func (h crawlerHandler) serveSitemap(w http.ResponseWriter, r *http.Request) {
78
+	w.Header().Set("Content-Type", "application/xml; charset=utf-8")
79
+	w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
80
+
81
+	fmt.Fprintln(w, xml.Header+`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
82
+	for _, p := range sitemapPaths {
83
+		loc := canonicalURL(h.baseURL, r, p)
84
+		if loc == "" {
85
+			continue
86
+		}
87
+		fmt.Fprint(w, "  <url><loc>")
88
+		_ = xml.EscapeText(w, []byte(loc))
89
+		fmt.Fprintln(w, "</loc></url>")
90
+	}
91
+	fmt.Fprintln(w, "</urlset>")
92
+}
93
+
94
+func canonicalURL(configuredBase string, r *http.Request, p string) string {
95
+	base := publicBaseURL(configuredBase, r)
96
+	if base == "" {
97
+		return ""
98
+	}
99
+	if p == "" {
100
+		p = "/"
101
+	}
102
+	if !strings.HasPrefix(p, "/") {
103
+		p = "/" + p
104
+	}
105
+	return base + p
106
+}
107
+
108
+func publicBaseURL(configuredBase string, r *http.Request) string {
109
+	if base := strings.TrimRight(strings.TrimSpace(configuredBase), "/"); base != "" {
110
+		return base
111
+	}
112
+	if r == nil || r.Host == "" {
113
+		return ""
114
+	}
115
+	host := strings.TrimSpace(r.Host)
116
+	if host == "" || strings.ContainsAny(host, "\r\n\t /\\") {
117
+		return ""
118
+	}
119
+	scheme := "http"
120
+	if r.TLS != nil {
121
+		scheme = "https"
122
+	}
123
+	return (&url.URL{Scheme: scheme, Host: host}).String()
124
+}
125
+
126
+func organizationStructuredData(baseURL string) template.JS {
127
+	if baseURL == "" {
128
+		return ""
129
+	}
130
+	payload := map[string]any{
131
+		"@context":    "https://schema.org",
132
+		"@type":       "Organization",
133
+		"name":        "shithub",
134
+		"url":         baseURL,
135
+		"logo":        baseURL + "/static/logo/shithub-mark.svg",
136
+		"description": defaultMetaDescription,
137
+		"sameAs": []string{
138
+			"https://github.com/tenseleyFlow/shithub",
139
+		},
140
+	}
141
+	raw, _ := json.Marshal(payload)
142
+	return template.JS(raw) // #nosec G203 -- payload is marshaled from server-owned constants and URLs.
143
+}
internal/web/handlers/seo_test.goadded
@@ -0,0 +1,29 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"net/http"
7
+	"net/http/httptest"
8
+	"testing"
9
+)
10
+
11
+func TestPublicBaseURLRejectsUnsafeRequestHostFallback(t *testing.T) {
12
+	t.Parallel()
13
+
14
+	req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
15
+	req.Host = "example.com\r\nSitemap: https://evil.test/sitemap.xml"
16
+	if got := publicBaseURL("", req); got != "" {
17
+		t.Fatalf("publicBaseURL accepted unsafe host = %q", got)
18
+	}
19
+}
20
+
21
+func TestPublicBaseURLPrefersConfiguredBase(t *testing.T) {
22
+	t.Parallel()
23
+
24
+	req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
25
+	req.Host = "untrusted.example"
26
+	if got, want := publicBaseURL("https://shithub.sh/", req), "https://shithub.sh"; got != want {
27
+		t.Fatalf("publicBaseURL = %q, want %q", got, want)
28
+	}
29
+}
internal/web/handlers/testfixtures_test.gomodified
@@ -14,11 +14,14 @@ func testTemplatesFS(t *testing.T) fs.FS {
1414
 	t.Helper()
1515
 	return fstest.MapFS{
1616
 		"_layout.html": &fstest.MapFile{
17
-			Data: []byte(`{{ define "layout" }}<!DOCTYPE html><html><head><title>{{ .Title }} · shithub</title></head><body>{{ template "page" . }}</body></html>{{ end }}`),
17
+			Data: []byte(`{{ define "layout" }}<!DOCTYPE html><html><head>{{ if stringField . "MetaDescription" }}<meta name="description" content="{{ stringField . "MetaDescription" }}">{{ end }}{{ with stringField . "CanonicalURL" }}<link rel="canonical" href="{{ . }}">{{ end }}<title>{{ .Title }} · shithub</title></head><body>{{ template "page" . }}</body></html>{{ end }}`),
1818
 		},
1919
 		"hello.html": &fstest.MapFile{
2020
 			Data: []byte(`{{ define "page" }}<main>shithub - GitHub. Open source. Without Copilot. Sprint 00 v{{ .Version }}</main>{{ end }}`),
2121
 		},
22
+		"about.html": &fstest.MapFile{
23
+			Data: []byte(`{{ define "page" }}<main>No hard feelings to GitHub. I just don't want AI training on my code.</main>{{ end }}`),
24
+		},
2225
 	}
2326
 }
2427
 
internal/web/render/render.gomodified
@@ -325,6 +325,16 @@ func funcMap(octicon OcticonResolver) template.FuncMap {
325325
 		// template data. Layout-level feature toggles use this so pages
326326
 		// backed by typed structs don't fail when the toggle is absent.
327327
 		"flag": dataFlag,
328
+		// stringField reads an optional string-ish field from map or
329
+		// struct template data. Shared document metadata uses this so
330
+		// typed page-data structs don't all have to define every optional
331
+		// SEO/social field.
332
+		"stringField": dataString,
333
+		// jsField returns only server-marked template.JS values. It is
334
+		// intentionally stricter than stringField so JSON-LD can be
335
+		// emitted from trusted marshaled data without giving templates a
336
+		// generic raw-JS escape hatch for arbitrary strings.
337
+		"jsField": dataJS,
328338
 		// csrfToken pulls the per-request token from the request context.
329339
 		// Templates use this in <input type="hidden" name="csrf_token">.
330340
 		"csrfToken": middleware.CSRFTokenForRequest,
@@ -353,31 +363,93 @@ func funcMap(octicon OcticonResolver) template.FuncMap {
353363
 }
354364
 
355365
 func dataFlag(data any, name string) bool {
366
+	field, ok := dataField(data, name)
367
+	if !ok {
368
+		return false
369
+	}
370
+	return truthyValue(field)
371
+}
372
+
373
+func dataString(data any, name string) string {
374
+	field, ok := dataField(data, name)
375
+	if !ok {
376
+		return ""
377
+	}
378
+	for field.Kind() == reflect.Pointer || field.Kind() == reflect.Interface {
379
+		if field.IsNil() {
380
+			return ""
381
+		}
382
+		field = field.Elem()
383
+	}
384
+	switch field.Kind() {
385
+	case reflect.String:
386
+		return field.String()
387
+	default:
388
+		if field.CanInterface() {
389
+			return fmt.Sprint(field.Interface())
390
+		}
391
+		return ""
392
+	}
393
+}
394
+
395
+func dataJS(data any, name string) template.JS {
396
+	field, ok := dataField(data, name)
397
+	if !ok {
398
+		return ""
399
+	}
400
+	if field.CanInterface() {
401
+		switch v := field.Interface().(type) {
402
+		case template.JS:
403
+			return v
404
+		case *template.JS:
405
+			if v != nil {
406
+				return *v
407
+			}
408
+		}
409
+	}
410
+	for field.Kind() == reflect.Pointer || field.Kind() == reflect.Interface {
411
+		if field.IsNil() {
412
+			return ""
413
+		}
414
+		field = field.Elem()
415
+		if field.CanInterface() {
416
+			if v, ok := field.Interface().(template.JS); ok {
417
+				return v
418
+			}
419
+		}
420
+	}
421
+	return ""
422
+}
423
+
424
+func dataField(data any, name string) (reflect.Value, bool) {
356425
 	v := reflect.ValueOf(data)
357426
 	if !v.IsValid() {
358
-		return false
427
+		return reflect.Value{}, false
359428
 	}
360429
 	for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
361430
 		if v.IsNil() {
362
-			return false
431
+			return reflect.Value{}, false
363432
 		}
364433
 		v = v.Elem()
365434
 	}
366435
 	switch v.Kind() {
367436
 	case reflect.Map:
368437
 		if v.Type().Key().Kind() != reflect.String {
369
-			return false
438
+			return reflect.Value{}, false
370439
 		}
371440
 		field := v.MapIndex(reflect.ValueOf(name))
372
-		return truthyValue(field)
441
+		if !field.IsValid() {
442
+			return reflect.Value{}, false
443
+		}
444
+		return field, true
373445
 	case reflect.Struct:
374446
 		field := v.FieldByName(name)
375447
 		if !field.IsValid() || !field.CanInterface() {
376
-			return false
448
+			return reflect.Value{}, false
377449
 		}
378
-		return truthyValue(field)
450
+		return field, true
379451
 	default:
380
-		return false
452
+		return reflect.Value{}, false
381453
 	}
382454
 }
383455
 
internal/web/render/render_test.gomodified
@@ -4,6 +4,7 @@ package render
44
 
55
 import (
66
 	"bytes"
7
+	"html/template"
78
 	"net/http/httptest"
89
 	"strings"
910
 	"testing"
@@ -106,6 +107,65 @@ func TestFlagHelperSupportsOptionalLayoutToggles(t *testing.T) {
106107
 	}
107108
 }
108109
 
110
+func TestStringFieldHelperSupportsOptionalLayoutMetadata(t *testing.T) {
111
+	t.Parallel()
112
+	fsys := fstest.MapFS{
113
+		"_layout.html": &fstest.MapFile{Data: []byte(
114
+			`{{ define "layout" }}<html><head>{{ with stringField . "MetaDescription" }}<meta name="description" content="{{ . }}">{{ end }}</head>{{ template "page" . }}</html>{{ end }}`,
115
+		)},
116
+		"page.html": &fstest.MapFile{Data: []byte(`{{ define "page" }}body{{ end }}`)},
117
+	}
118
+	r, err := New(fsys, Options{})
119
+	if err != nil {
120
+		t.Fatalf("New: %v", err)
121
+	}
122
+	var buf bytes.Buffer
123
+	type typedPageData struct {
124
+		Title string
125
+	}
126
+	if err := r.Render(&buf, "page", typedPageData{Title: "typed"}); err != nil {
127
+		t.Fatalf("render typed data without metadata: %v", err)
128
+	}
129
+	if strings.Contains(buf.String(), `name="description"`) {
130
+		t.Fatalf("absent typed metadata rendered meta tag: %q", buf.String())
131
+	}
132
+	buf.Reset()
133
+	if err := r.Render(&buf, "page", map[string]any{"MetaDescription": "self-hosted git forge"}); err != nil {
134
+		t.Fatalf("render map data with metadata: %v", err)
135
+	}
136
+	if !strings.Contains(buf.String(), `content="self-hosted git forge"`) {
137
+		t.Fatalf("map metadata did not render: %q", buf.String())
138
+	}
139
+}
140
+
141
+func TestJSFieldHelperOnlyRendersTrustedJS(t *testing.T) {
142
+	t.Parallel()
143
+	fsys := fstest.MapFS{
144
+		"_layout.html": &fstest.MapFile{Data: []byte(
145
+			`{{ define "layout" }}<html><head>{{ with jsField . "StructuredData" }}<script type="application/ld+json">{{ . }}</script>{{ end }}</head>{{ template "page" . }}</html>{{ end }}`,
146
+		)},
147
+		"page.html": &fstest.MapFile{Data: []byte(`{{ define "page" }}body{{ end }}`)},
148
+	}
149
+	r, err := New(fsys, Options{})
150
+	if err != nil {
151
+		t.Fatalf("New: %v", err)
152
+	}
153
+	var buf bytes.Buffer
154
+	if err := r.Render(&buf, "page", map[string]any{"StructuredData": `{"unsafe":true}`}); err != nil {
155
+		t.Fatalf("render map data with plain string metadata: %v", err)
156
+	}
157
+	if strings.Contains(buf.String(), "application/ld+json") {
158
+		t.Fatalf("plain string JSON-LD should not render as trusted JS: %q", buf.String())
159
+	}
160
+	buf.Reset()
161
+	if err := r.Render(&buf, "page", map[string]any{"StructuredData": template.JS(`{"@type":"Organization"}`)}); err != nil {
162
+		t.Fatalf("render map data with trusted metadata: %v", err)
163
+	}
164
+	if !strings.Contains(buf.String(), `{"@type":"Organization"}`) {
165
+		t.Fatalf("trusted JSON-LD did not render: %q", buf.String())
166
+	}
167
+}
168
+
109169
 // Regression test for the inbound deferral from S30 dogfood: a partial
110170
 // at `profile/_tabs.html` that defines `{{ define "tabs" }}` was
111171
 // silently registered as an unparsed page. A page that called
internal/web/server.gomodified
@@ -146,6 +146,7 @@ func Run(ctx context.Context, opts Options) error {
146146
 		LogoSVG:      string(logoBytes),
147147
 		SessionStore: sessionStore,
148148
 		Pool:         pool,
149
+		BaseURL:      cfg.Auth.BaseURL,
149150
 		CookieSecure: cfg.Session.Secure,
150151
 	}
151152
 	if pool != nil {
internal/web/static/css/shithub.cssmodified
@@ -879,6 +879,88 @@ code {
879879
   line-height: 1.5;
880880
 }
881881
 
882
+/* ========== About page ========== */
883
+
884
+.shithub-about {
885
+  max-width: 920px;
886
+  margin: 4rem auto;
887
+  padding: 0 1.5rem 2rem;
888
+}
889
+.shithub-about-hero {
890
+  max-width: 760px;
891
+  margin-bottom: 2rem;
892
+}
893
+.shithub-about-kicker {
894
+  margin: 0 0 0.75rem;
895
+  color: var(--accent-fg);
896
+  font-weight: 600;
897
+  font-size: 0.9rem;
898
+}
899
+.shithub-about h1 {
900
+  margin: 0 0 1rem;
901
+  font-size: 2.75rem;
902
+  line-height: 1.08;
903
+}
904
+.shithub-about-hero p,
905
+.shithub-about-section p {
906
+  color: var(--fg-muted);
907
+  font-size: 1.05rem;
908
+  line-height: 1.65;
909
+}
910
+.shithub-about-section {
911
+  margin-top: 2rem;
912
+}
913
+.shithub-about-section h2 {
914
+  margin: 0 0 0.75rem;
915
+  font-size: 1.35rem;
916
+}
917
+.shithub-about-list {
918
+  list-style: none;
919
+  margin: 0;
920
+  padding: 0;
921
+  display: grid;
922
+  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
923
+  gap: 1rem;
924
+}
925
+.shithub-about-list li {
926
+  border: 1px solid var(--border-default);
927
+  border-radius: 6px;
928
+  padding: 1rem;
929
+  background: var(--canvas-subtle);
930
+}
931
+.shithub-about-list strong {
932
+  display: block;
933
+  margin-bottom: 0.35rem;
934
+}
935
+.shithub-about-list span {
936
+  display: block;
937
+  color: var(--fg-muted);
938
+  font-size: 0.95rem;
939
+  line-height: 1.5;
940
+}
941
+.shithub-about-actions {
942
+  display: flex;
943
+  flex-wrap: wrap;
944
+  gap: 0.75rem;
945
+  margin-top: 2rem;
946
+}
947
+.shithub-about-actions a {
948
+  display: inline-block;
949
+  padding: 0.5rem 1rem;
950
+  border-radius: 6px;
951
+  text-decoration: none;
952
+  font-weight: 500;
953
+  font-size: 0.95rem;
954
+}
955
+@media (max-width: 640px) {
956
+  .shithub-about {
957
+    margin: 2.5rem auto;
958
+  }
959
+  .shithub-about h1 {
960
+    font-size: 2rem;
961
+  }
962
+}
963
+
882964
 /* ========== Error pages ========== */
883965
 
884966
 .error-page {
internal/web/templates/_layout.htmlmodified
@@ -18,10 +18,18 @@
1818
   <meta charset="UTF-8">
1919
   <meta name="viewport" content="width=device-width, initial-scale=1">
2020
   <meta name="color-scheme" content="light dark">
21
-  <meta name="description" content="shithub — GitHub. Open source. Without Copilot.">
22
-  {{ if .OGTitle }}<meta property="og:title" content="{{ .OGTitle }}">{{ end }}
23
-  {{ if .OGDescription }}<meta property="og:description" content="{{ .OGDescription }}">{{ end }}
24
-  {{ if .OGImage }}<meta property="og:image" content="{{ .OGImage }}">{{ end }}
21
+  {{ if stringField . "MetaDescription" }}<meta name="description" content="{{ stringField . "MetaDescription" }}">{{ else }}<meta name="description" content="shithub is an AGPL self-hosted GitHub alternative: Git repositories, pull requests, issues, Actions-style CI, organizations, and code search without Copilot.">{{ end }}
22
+  {{ with stringField . "CanonicalURL" }}<link rel="canonical" href="{{ . }}">{{ end }}
23
+  <meta property="og:site_name" content="shithub">
24
+  {{ if stringField . "OGType" }}<meta property="og:type" content="{{ stringField . "OGType" }}">{{ else }}<meta property="og:type" content="website">{{ end }}
25
+  {{ if stringField . "OGTitle" }}<meta property="og:title" content="{{ stringField . "OGTitle" }}">{{ else }}<meta property="og:title" content="{{ .Title }} · shithub">{{ end }}
26
+  {{ if stringField . "OGDescription" }}<meta property="og:description" content="{{ stringField . "OGDescription" }}">{{ else if stringField . "MetaDescription" }}<meta property="og:description" content="{{ stringField . "MetaDescription" }}">{{ end }}
27
+  {{ with stringField . "CanonicalURL" }}<meta property="og:url" content="{{ . }}">{{ end }}
28
+  {{ with stringField . "OGImage" }}<meta property="og:image" content="{{ . }}">{{ end }}
29
+  {{ if stringField . "OGImage" }}<meta name="twitter:card" content="summary_large_image">{{ else }}<meta name="twitter:card" content="summary">{{ end }}
30
+  {{ if stringField . "OGTitle" }}<meta name="twitter:title" content="{{ stringField . "OGTitle" }}">{{ else }}<meta name="twitter:title" content="{{ .Title }} · shithub">{{ end }}
31
+  {{ if stringField . "OGDescription" }}<meta name="twitter:description" content="{{ stringField . "OGDescription" }}">{{ else if stringField . "MetaDescription" }}<meta name="twitter:description" content="{{ stringField . "MetaDescription" }}">{{ end }}
32
+  {{ with jsField . "StructuredData" }}<script type="application/ld+json">{{ . }}</script>{{ end }}
2533
   <title>{{ .Title }} · shithub</title>
2634
   <link rel="icon" type="image/svg+xml" href="/static/logo/favicon.svg">
2735
   <link rel="stylesheet" href="/static/primer/primer.css" onerror="this.remove()">
internal/web/templates/about.htmladded
@@ -0,0 +1,64 @@
1
+{{ define "page" -}}
2
+<section class="shithub-about">
3
+  <header class="shithub-about-hero">
4
+    <p class="shithub-about-kicker">Self-hosted GitHub alternative</p>
5
+    <h1>Git hosting with GitHub muscle memory and no AI training on your code.</h1>
6
+    <p>
7
+      shithub is an AGPL git forge for teams who like GitHub's product,
8
+      workflows, and interface, but want a self-hostable home for source
9
+      code that is not part of a Copilot training pipeline.
10
+    </p>
11
+  </header>
12
+
13
+  <section class="shithub-about-section">
14
+    <h2>No hard feelings</h2>
15
+    <p>
16
+      GitHub is good software. The goal here is not to pretend otherwise.
17
+      The issue is simpler: I just don't want AI training on my code.
18
+      shithub keeps the parts that work: repositories, pull requests,
19
+      issues, code review, organizations, search, and Actions-style CI,
20
+      while making the hosted service and the self-hosted product open
21
+      under the AGPL.
22
+    </p>
23
+  </section>
24
+
25
+  <section class="shithub-about-section">
26
+    <h2>What shithub is trying to be</h2>
27
+    <ul class="shithub-about-list">
28
+      <li>
29
+        <strong>A real forge, not a demo.</strong>
30
+        <span>Git repositories, HTTPS push and pull, reviews, required checks, webhooks, notifications, teams, scoped tokens, and code search are product requirements, not stretch goals.</span>
31
+      </li>
32
+      <li>
33
+        <strong>Familiar on purpose.</strong>
34
+        <span>The closer the GitHub-style UI and workflows are, the less expensive it is for a team to move.</span>
35
+      </li>
36
+      <li>
37
+        <strong>Open when hosted.</strong>
38
+        <span>AGPLv3 means modified network services have to publish the matching source, so hosted shithub instances stay accountable to their users.</span>
39
+      </li>
40
+      <li>
41
+        <strong>Honest about gaps.</strong>
42
+        <span>The public docs and README call out what is live, what is rough, and what is still in flight.</span>
43
+      </li>
44
+    </ul>
45
+  </section>
46
+
47
+  <section class="shithub-about-section">
48
+    <h2>Community posture</h2>
49
+    <p>
50
+      Contributions are welcome under the same AGPL terms, security reports
51
+      go through coordinated disclosure, and the project follows the
52
+      Contributor Covenant. Be direct, be civil, and keep the work focused
53
+      on making an open GitHub alternative that people can actually use.
54
+    </p>
55
+  </section>
56
+
57
+  <nav class="shithub-about-actions" aria-label="About links">
58
+    <a class="shithub-landing-cta-primary" href="/signup">Create an account</a>
59
+    <a class="shithub-landing-cta-secondary" href="https://docs.shithub.sh/self-host/deploy.html">Self-host shithub</a>
60
+    <a class="shithub-landing-cta-secondary" href="/shithub/shithub">Read the source</a>
61
+    <a class="shithub-landing-cta-secondary" href="https://docs.shithub.sh/security.html">Security policy</a>
62
+  </nav>
63
+</section>
64
+{{- end }}