tenseleyflow/shithub / b94d92c

Browse files

Add web server shell: hello page, static asset server, /healthz, /readyz

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b94d92cc848f73a63c493b7fed2bb192e49a57ab
Parents
730b0d2
Tree
f99e1cf

12 changed files

StatusFile+-
A internal/web/embed.go 40 0
A internal/web/handlers/handlers.go 52 0
A internal/web/handlers/handlers_test.go 91 0
A internal/web/handlers/health.go 20 0
A internal/web/handlers/hello.go 41 0
A internal/web/handlers/static.go 21 0
A internal/web/handlers/testfixtures_test.go 37 0
A internal/web/render/render.go 95 0
A internal/web/server.go 97 0
A internal/web/static/css/shithub.css 129 0
A internal/web/templates/_layout.html 31 0
A internal/web/templates/hello.html 26 0
internal/web/embed.goadded
@@ -0,0 +1,40 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package web
4
+
5
+import (
6
+	"embed"
7
+	"io/fs"
8
+)
9
+
10
+//go:embed all:templates
11
+var templatesFS embed.FS
12
+
13
+//go:embed all:static
14
+var staticFS embed.FS
15
+
16
+// TemplatesFS returns the read-only filesystem rooted at the templates dir.
17
+func TemplatesFS() fs.FS {
18
+	sub, err := fs.Sub(templatesFS, "templates")
19
+	if err != nil {
20
+		// embed.FS layout is verified at compile time; this can't fail at
21
+		// runtime once the build succeeds.
22
+		panic(err)
23
+	}
24
+	return sub
25
+}
26
+
27
+// StaticFS returns the read-only filesystem rooted at the static dir.
28
+func StaticFS() fs.FS {
29
+	sub, err := fs.Sub(staticFS, "static")
30
+	if err != nil {
31
+		panic(err)
32
+	}
33
+	return sub
34
+}
35
+
36
+// LogoSVG returns the canonical mascot SVG bytes (full mark, with tentacles
37
+// and pitchfork).
38
+func LogoSVG() ([]byte, error) {
39
+	return staticFS.ReadFile("static/logo/shithub.svg")
40
+}
internal/web/handlers/handlers.goadded
@@ -0,0 +1,52 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package handlers registers HTTP handlers on the web server's mux.
4
+//
5
+// S00 ships only the hello page, static asset server, and health endpoints.
6
+// Each future sprint adds its own routes via this package.
7
+package handlers
8
+
9
+import (
10
+	"fmt"
11
+	"io/fs"
12
+	"log/slog"
13
+	"net/http"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/web/render"
16
+)
17
+
18
+// Deps holds the dependencies the handlers need. The web package owns the
19
+// embedded filesystems and constructs Deps; this package stays decoupled
20
+// from the embed.FS instances so it remains testable.
21
+type Deps struct {
22
+	Logger      *slog.Logger
23
+	TemplatesFS fs.FS
24
+	StaticFS    fs.FS
25
+	LogoSVG     string
26
+}
27
+
28
+// Register wires every S00 route into mux. Later sprints' Register entrypoints
29
+// are called from here; for S00 the surface is small.
30
+func Register(mux *http.ServeMux, deps Deps) error {
31
+	if deps.Logger == nil {
32
+		return fmt.Errorf("handlers.Register: nil Logger")
33
+	}
34
+	if deps.TemplatesFS == nil {
35
+		return fmt.Errorf("handlers.Register: nil TemplatesFS")
36
+	}
37
+	if deps.StaticFS == nil {
38
+		return fmt.Errorf("handlers.Register: nil StaticFS")
39
+	}
40
+
41
+	r, err := render.New(deps.TemplatesFS)
42
+	if err != nil {
43
+		return fmt.Errorf("renderer: %w", err)
44
+	}
45
+
46
+	mux.Handle("GET /static/", http.StripPrefix("/static/", staticFileServer(deps.StaticFS)))
47
+	mux.HandleFunc("GET /healthz", healthz)
48
+	mux.HandleFunc("GET /readyz", readyz)
49
+	mux.Handle("GET /{$}", helloHandler{render: r, logoSVG: deps.LogoSVG, logger: deps.Logger})
50
+
51
+	return nil
52
+}
internal/web/handlers/handlers_test.goadded
@@ -0,0 +1,91 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"io"
7
+	"log/slog"
8
+	"net/http"
9
+	"net/http/httptest"
10
+	"strings"
11
+	"testing"
12
+)
13
+
14
+func TestHandlers(t *testing.T) {
15
+	t.Parallel()
16
+
17
+	mux := http.NewServeMux()
18
+	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
19
+	if err := Register(mux, Deps{
20
+		Logger:      logger,
21
+		TemplatesFS: testTemplatesFS(t),
22
+		StaticFS:    testStaticFS(t),
23
+		LogoSVG:     `<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`,
24
+	}); err != nil {
25
+		t.Fatalf("Register: %v", err)
26
+	}
27
+
28
+	tests := []struct {
29
+		name        string
30
+		path        string
31
+		wantStatus  int
32
+		wantBodyAny []string
33
+		wantHeader  map[string]string
34
+	}{
35
+		{
36
+			name:        "hello page",
37
+			path:        "/",
38
+			wantStatus:  http.StatusOK,
39
+			wantBodyAny: []string{"shithub", "GitHub. Open source. Without Copilot.", "Sprint 00"},
40
+			wantHeader:  map[string]string{"Content-Type": "text/html; charset=utf-8"},
41
+		},
42
+		{
43
+			name:        "healthz",
44
+			path:        "/healthz",
45
+			wantStatus:  http.StatusOK,
46
+			wantBodyAny: []string{"ok"},
47
+		},
48
+		{
49
+			name:        "readyz",
50
+			path:        "/readyz",
51
+			wantStatus:  http.StatusOK,
52
+			wantBodyAny: []string{"ready"},
53
+		},
54
+		{
55
+			name:        "logo svg",
56
+			path:        "/static/logo/shithub.svg",
57
+			wantStatus:  http.StatusOK,
58
+			wantBodyAny: []string{"<svg", "shithub"},
59
+		},
60
+		{
61
+			name:       "unknown route 404",
62
+			path:       "/this-path-does-not-exist",
63
+			wantStatus: http.StatusNotFound,
64
+		},
65
+	}
66
+
67
+	for _, tc := range tests {
68
+		t.Run(tc.name, func(t *testing.T) {
69
+			t.Parallel()
70
+
71
+			req := httptest.NewRequest(http.MethodGet, tc.path, nil)
72
+			rec := httptest.NewRecorder()
73
+			mux.ServeHTTP(rec, req)
74
+
75
+			if rec.Code != tc.wantStatus {
76
+				t.Fatalf("status: got %d, want %d (body=%q)", rec.Code, tc.wantStatus, rec.Body.String())
77
+			}
78
+			body := rec.Body.String()
79
+			for _, want := range tc.wantBodyAny {
80
+				if !strings.Contains(body, want) {
81
+					t.Errorf("body missing %q\nbody=%q", want, body)
82
+				}
83
+			}
84
+			for k, want := range tc.wantHeader {
85
+				if got := rec.Header().Get(k); got != want {
86
+					t.Errorf("header %s: got %q, want %q", k, got, want)
87
+				}
88
+			}
89
+		})
90
+	}
91
+}
internal/web/handlers/health.goadded
@@ -0,0 +1,20 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import "net/http"
6
+
7
+// healthz returns 200 if the process is alive. No dependency checks.
8
+func healthz(w http.ResponseWriter, _ *http.Request) {
9
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
10
+	w.Header().Set("Cache-Control", "no-store")
11
+	_, _ = w.Write([]byte("ok\n"))
12
+}
13
+
14
+// readyz returns 200 when the server is ready to take traffic. S00 has no
15
+// dependencies to check; S01 wires Postgres into here, S04 wires storage.
16
+func readyz(w http.ResponseWriter, _ *http.Request) {
17
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
18
+	w.Header().Set("Cache-Control", "no-store")
19
+	_, _ = w.Write([]byte("ready\n"))
20
+}
internal/web/handlers/hello.goadded
@@ -0,0 +1,41 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"html/template"
7
+	"log/slog"
8
+	"net/http"
9
+
10
+	"github.com/tenseleyFlow/shithub/internal/version"
11
+	"github.com/tenseleyFlow/shithub/internal/web/render"
12
+)
13
+
14
+type helloHandler struct {
15
+	render  *render.Renderer
16
+	logoSVG string
17
+	logger  *slog.Logger
18
+}
19
+
20
+type helloData struct {
21
+	Title   string
22
+	Version string
23
+	Commit  string
24
+	BuiltAt string
25
+	LogoSVG template.HTML
26
+}
27
+
28
+func (h helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
29
+	data := helloData{
30
+		Title:   "Welcome",
31
+		Version: version.Version,
32
+		Commit:  version.Commit,
33
+		BuiltAt: version.BuiltAt,
34
+		LogoSVG: template.HTML(h.logoSVG), // #nosec G203 — embedded server-owned asset
35
+	}
36
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
37
+	if err := h.render.Render(w, "hello", data); err != nil {
38
+		h.logger.Error("render hello", "error", err)
39
+		http.Error(w, "internal server error", http.StatusInternalServerError)
40
+	}
41
+}
internal/web/handlers/static.goadded
@@ -0,0 +1,21 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"io/fs"
7
+	"net/http"
8
+)
9
+
10
+// staticFileServer serves files from the embedded static FS with sane
11
+// caching headers. S02 will replace this with a content-hashed asset
12
+// pipeline; S00 keeps the surface minimal.
13
+func staticFileServer(staticFS fs.FS) http.Handler {
14
+	fileServer := http.FileServer(http.FS(staticFS))
15
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16
+		// Conservative caching: short TTL + must-revalidate. Long-cache
17
+		// behavior moves to fingerprinted URLs in S02.
18
+		w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate")
19
+		fileServer.ServeHTTP(w, r)
20
+	})
21
+}
internal/web/handlers/testfixtures_test.goadded
@@ -0,0 +1,37 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package handlers
4
+
5
+import (
6
+	"io/fs"
7
+	"testing"
8
+	"testing/fstest"
9
+)
10
+
11
+// testTemplatesFS returns a minimal in-memory templates filesystem matching
12
+// the layout the production embed produces (after Sub-rooting).
13
+func testTemplatesFS(t *testing.T) fs.FS {
14
+	t.Helper()
15
+	return fstest.MapFS{
16
+		"_layout.html": &fstest.MapFile{
17
+			Data: []byte(`{{ define "layout" }}<!DOCTYPE html><html><head><title>{{ .Title }} · shithub</title></head><body>{{ template "page" . }}</body></html>{{ end }}`),
18
+		},
19
+		"hello.html": &fstest.MapFile{
20
+			Data: []byte(`{{ define "page" }}<main>shithub - GitHub. Open source. Without Copilot. Sprint 00 v{{ .Version }}</main>{{ end }}`),
21
+		},
22
+	}
23
+}
24
+
25
+// testStaticFS returns a minimal in-memory static filesystem matching the
26
+// layout the production embed produces.
27
+func testStaticFS(t *testing.T) fs.FS {
28
+	t.Helper()
29
+	return fstest.MapFS{
30
+		"logo/shithub.svg": &fstest.MapFile{
31
+			Data: []byte(`<svg xmlns="http://www.w3.org/2000/svg"><title>shithub</title></svg>`),
32
+		},
33
+		"css/shithub.css": &fstest.MapFile{
34
+			Data: []byte(`body { color: black; }`),
35
+		},
36
+	}
37
+}
internal/web/render/render.goadded
@@ -0,0 +1,95 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package render owns the html/template loading and rendering pipeline.
4
+// S02 will broaden this with helpers (relativeTime, urlFor, octicon, etc.);
5
+// S00 ships only what the hello page needs.
6
+package render
7
+
8
+import (
9
+	"bytes"
10
+	"fmt"
11
+	"html/template"
12
+	"io"
13
+	"io/fs"
14
+	"path"
15
+	"strings"
16
+)
17
+
18
+// Renderer holds parsed templates indexed by page name.
19
+type Renderer struct {
20
+	pages map[string]*template.Template
21
+}
22
+
23
+// New parses every page template under tmplFS. A "page template" is any file
24
+// at the root of tmplFS that does NOT begin with an underscore. Files that
25
+// begin with an underscore (e.g. "_layout.html") are partials, parsed once
26
+// into every page.
27
+func New(tmplFS fs.FS) (*Renderer, error) {
28
+	entries, err := fs.ReadDir(tmplFS, ".")
29
+	if err != nil {
30
+		return nil, fmt.Errorf("read template root: %w", err)
31
+	}
32
+
33
+	var (
34
+		partialNames []string
35
+		pageNames    []string
36
+	)
37
+	for _, e := range entries {
38
+		if e.IsDir() {
39
+			continue
40
+		}
41
+		name := e.Name()
42
+		if !strings.HasSuffix(name, ".html") {
43
+			continue
44
+		}
45
+		if strings.HasPrefix(name, "_") {
46
+			partialNames = append(partialNames, name)
47
+		} else {
48
+			pageNames = append(pageNames, name)
49
+		}
50
+	}
51
+
52
+	r := &Renderer{pages: make(map[string]*template.Template, len(pageNames))}
53
+	for _, page := range pageNames {
54
+		t := template.New(page).Funcs(funcMap())
55
+		all := append([]string{}, partialNames...)
56
+		all = append(all, page)
57
+		// Convert to filesystem paths.
58
+		for i := range all {
59
+			all[i] = path.Clean(all[i])
60
+		}
61
+		parsed, err := t.ParseFS(tmplFS, all...)
62
+		if err != nil {
63
+			return nil, fmt.Errorf("parse %s: %w", page, err)
64
+		}
65
+		r.pages[strings.TrimSuffix(page, ".html")] = parsed
66
+	}
67
+	return r, nil
68
+}
69
+
70
+// Render writes the named page to w using data as the template root context.
71
+// The page's templates execute the layout via {{ template "layout" . }}.
72
+func (r *Renderer) Render(w io.Writer, name string, data any) error {
73
+	t, ok := r.pages[name]
74
+	if !ok {
75
+		return fmt.Errorf("render: unknown page %q", name)
76
+	}
77
+	// Pages declare a "page" block; the layout calls into it.
78
+	var buf bytes.Buffer
79
+	if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
80
+		return fmt.Errorf("execute %s: %w", name, err)
81
+	}
82
+	_, err := w.Write(buf.Bytes())
83
+	return err
84
+}
85
+
86
+func funcMap() template.FuncMap {
87
+	return template.FuncMap{
88
+		// safeHTML embeds trusted HTML directly. Callers MUST ensure the
89
+		// input is server-controlled — never user input. S25's markdown
90
+		// pipeline supplies the canonical helper for user content.
91
+		"safeHTML": func(s string) template.HTML {
92
+			return template.HTML(s) // #nosec G203 — trusted-input only
93
+		},
94
+	}
95
+}
internal/web/server.goadded
@@ -0,0 +1,97 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package web boots the shithub HTTP server. S00 stands up only the bare
4
+// shell — the hello page, static assets, and /healthz. S02 (web shell)
5
+// fleshes out the middleware stack, sessions, error pages, and Primer-themed
6
+// base templates. Every later sprint adds routes via internal/web/handlers.
7
+package web
8
+
9
+import (
10
+	"context"
11
+	"errors"
12
+	"fmt"
13
+	"log/slog"
14
+	"net/http"
15
+	"os"
16
+	"os/signal"
17
+	"syscall"
18
+	"time"
19
+
20
+	"github.com/tenseleyFlow/shithub/internal/web/handlers"
21
+)
22
+
23
+// Options configures the web server.
24
+type Options struct {
25
+	Addr string
26
+}
27
+
28
+// Run boots the web server and blocks until shutdown.
29
+//
30
+// It listens for SIGINT/SIGTERM and gracefully drains in-flight requests on
31
+// exit. The S00 surface is intentionally minimal; later sprints add the full
32
+// middleware stack, session store, and rendering pipeline.
33
+func Run(ctx context.Context, opts Options) error {
34
+	if opts.Addr == "" {
35
+		opts.Addr = ":8080"
36
+	}
37
+
38
+	logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
39
+		Level: slog.LevelInfo,
40
+	}))
41
+
42
+	logoBytes, err := LogoSVG()
43
+	if err != nil {
44
+		return fmt.Errorf("load logo: %w", err)
45
+	}
46
+
47
+	mux := http.NewServeMux()
48
+	if err := handlers.Register(mux, handlers.Deps{
49
+		Logger:      logger,
50
+		TemplatesFS: TemplatesFS(),
51
+		StaticFS:    StaticFS(),
52
+		LogoSVG:     string(logoBytes),
53
+	}); err != nil {
54
+		return fmt.Errorf("register handlers: %w", err)
55
+	}
56
+
57
+	srv := &http.Server{
58
+		Addr:              opts.Addr,
59
+		Handler:           mux,
60
+		ReadHeaderTimeout: 10 * time.Second,
61
+		ReadTimeout:       30 * time.Second,
62
+		WriteTimeout:      30 * time.Second,
63
+		IdleTimeout:       120 * time.Second,
64
+	}
65
+
66
+	errCh := make(chan error, 1)
67
+	go func() {
68
+		logger.Info("shithub web server starting", "addr", opts.Addr)
69
+		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
70
+			errCh <- err
71
+		}
72
+		close(errCh)
73
+	}()
74
+
75
+	sigCh := make(chan os.Signal, 1)
76
+	signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
77
+	defer signal.Stop(sigCh)
78
+
79
+	select {
80
+	case err, ok := <-errCh:
81
+		if !ok {
82
+			return nil
83
+		}
84
+		return err
85
+	case sig := <-sigCh:
86
+		logger.Info("shutdown signal received", "signal", sig.String())
87
+	case <-ctx.Done():
88
+		logger.Info("context canceled, shutting down")
89
+	}
90
+
91
+	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
92
+	defer cancel()
93
+	if err := srv.Shutdown(shutdownCtx); err != nil {
94
+		return fmt.Errorf("shutdown: %w", err)
95
+	}
96
+	return nil
97
+}
internal/web/static/css/shithub.cssadded
@@ -0,0 +1,129 @@
1
+/* SPDX-License-Identifier: AGPL-3.0-or-later */
2
+/*
3
+ * shithub — base styles.
4
+ *
5
+ * S00 ships a tiny stylesheet sufficient for the hello page across light /
6
+ * dark / auto / high-contrast themes. S02 introduces the full theme system
7
+ * via Primer primitives; this file becomes one layer in a stack.
8
+ *
9
+ * Color tokens follow Primer's naming so the migration in S02 is mechanical.
10
+ */
11
+
12
+:root {
13
+  --canvas-default: #ffffff;
14
+  --canvas-subtle: #f6f8fa;
15
+  --fg-default: #1f2328;
16
+  --fg-muted: #59636e;
17
+  --border-default: #d0d7de;
18
+  --accent-fg: #0969da;
19
+  --danger-fg: #cf222e;
20
+  --shithub-mark: var(--danger-fg);
21
+}
22
+
23
+[data-theme="dark"] {
24
+  --canvas-default: #0d1117;
25
+  --canvas-subtle: #161b22;
26
+  --fg-default: #f0f6fc;
27
+  --fg-muted: #9198a1;
28
+  --border-default: #3d444d;
29
+  --accent-fg: #4493f8;
30
+  --danger-fg: #f85149;
31
+}
32
+
33
+[data-theme="high-contrast"] {
34
+  --canvas-default: #000000;
35
+  --canvas-subtle: #0a0c10;
36
+  --fg-default: #ffffff;
37
+  --fg-muted: #d9dee3;
38
+  --border-default: #7a828e;
39
+  --accent-fg: #71b7ff;
40
+  --danger-fg: #ff6a69;
41
+}
42
+
43
+* {
44
+  box-sizing: border-box;
45
+}
46
+
47
+html, body {
48
+  margin: 0;
49
+  padding: 0;
50
+  background: var(--canvas-default);
51
+  color: var(--fg-default);
52
+  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
53
+  font-size: 16px;
54
+  line-height: 1.5;
55
+}
56
+
57
+a {
58
+  color: var(--accent-fg);
59
+  text-decoration: none;
60
+}
61
+a:hover { text-decoration: underline; }
62
+
63
+.hello {
64
+  max-width: 640px;
65
+  margin: 4rem auto;
66
+  padding: 2rem 1.5rem;
67
+  text-align: center;
68
+}
69
+
70
+.hello-logo {
71
+  margin: 0 auto 1.5rem;
72
+  width: 160px;
73
+  height: 160px;
74
+  color: var(--shithub-mark);
75
+}
76
+
77
+.hello-logo svg {
78
+  width: 100%;
79
+  height: 100%;
80
+}
81
+
82
+.hello-title {
83
+  font-size: 2.75rem;
84
+  margin: 0 0 0.5rem;
85
+  letter-spacing: -0.02em;
86
+}
87
+
88
+.hello-tagline {
89
+  color: var(--fg-muted);
90
+  font-size: 1.15rem;
91
+  margin: 0 0 2rem;
92
+}
93
+
94
+.hello-meta {
95
+  display: grid;
96
+  grid-template-columns: max-content 1fr;
97
+  gap: 0.25rem 1rem;
98
+  max-width: 24rem;
99
+  margin: 0 auto 2rem;
100
+  padding: 1rem 1.5rem;
101
+  background: var(--canvas-subtle);
102
+  border: 1px solid var(--border-default);
103
+  border-radius: 6px;
104
+  text-align: left;
105
+  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
106
+  font-size: 0.875rem;
107
+}
108
+
109
+.hello-meta dt {
110
+  color: var(--fg-muted);
111
+}
112
+
113
+.hello-meta dd {
114
+  margin: 0;
115
+}
116
+
117
+.hello-status {
118
+  color: var(--fg-muted);
119
+  margin: 0 auto 2rem;
120
+  max-width: 36rem;
121
+}
122
+
123
+.hello-links {
124
+  display: flex;
125
+  justify-content: center;
126
+  gap: 1rem;
127
+  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
128
+  font-size: 0.875rem;
129
+}
internal/web/templates/_layout.htmladded
@@ -0,0 +1,31 @@
1
+{{ define "layout" -}}
2
+<!DOCTYPE html>
3
+<html lang="en" data-theme="auto">
4
+<head>
5
+  <script>
6
+    // Theme flash avoidance: read cookie or system preference and apply
7
+    // before any CSS computes. Establishes the dark/light/auto/high-contrast
8
+    // contract S02 will broaden.
9
+    (function () {
10
+      var match = document.cookie.match(/(?:^|; )theme=([^;]+)/);
11
+      var theme = match ? decodeURIComponent(match[1]) : "auto";
12
+      if (theme === "auto") {
13
+        theme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
14
+      }
15
+      document.documentElement.setAttribute("data-theme", theme);
16
+    })();
17
+  </script>
18
+  <meta charset="UTF-8">
19
+  <meta name="viewport" content="width=device-width, initial-scale=1">
20
+  <meta name="color-scheme" content="light dark">
21
+  <meta name="description" content="shithub — GitHub. Open source. Without Copilot.">
22
+  <title>{{ .Title }} · shithub</title>
23
+  <link rel="icon" type="image/svg+xml" href="/static/logo/favicon.svg">
24
+  <link rel="stylesheet" href="/static/css/shithub.css">
25
+  <link rel="stylesheet" href="/static/primer/primer.css" onerror="this.remove()">
26
+</head>
27
+<body>
28
+{{ template "page" . }}
29
+</body>
30
+</html>
31
+{{- end }}
internal/web/templates/hello.htmladded
@@ -0,0 +1,26 @@
1
+{{ define "page" -}}
2
+<main class="hello">
3
+  <div class="hello-logo" aria-hidden="true">
4
+    {{ .LogoSVG }}
5
+  </div>
6
+  <h1 class="hello-title">shithub</h1>
7
+  <p class="hello-tagline">GitHub. Open source. Without Copilot.</p>
8
+
9
+  <dl class="hello-meta">
10
+    <dt>Version</dt><dd>{{ .Version }}</dd>
11
+    <dt>Commit</dt><dd>{{ .Commit }}</dd>
12
+    <dt>Built</dt><dd>{{ .BuiltAt }}</dd>
13
+  </dl>
14
+
15
+  <p class="hello-status">
16
+    Pre-launch. Sprint 00 — project scaffolding — is shipping. Every later
17
+    sprint adds real product surface; see the planning index for the full
18
+    roadmap.
19
+  </p>
20
+
21
+  <nav class="hello-links" aria-label="Project links">
22
+    <a href="/healthz">/healthz</a>
23
+    <a href="/readyz">/readyz</a>
24
+  </nav>
25
+</main>
26
+{{- end }}