vitrine

styling pass, plus heading anchors

commit c213fcb8070910f546035d726fd63f942987c3a5 · parent ef04c20893 · browse this tree
Ximianth, 2026-07-23 21:05:59 UTC

- CSS moved to an embedded style.css, served at /-/style.css.
  The /-/ prefix is reserved for server assets, so it can't collide
  with a configured root name.
- Prose insets 1.5rem under flush-left headings; code blocks inset
  further with a border and accent edge.  Accent color throughout.
- Headings get slug ids (own slugifier, plus goldmark's WithAutoHeadingID
  and a per-document IDs registry for deterministic -2/-3 dedup suffixes)
  and a chainlink anchor appended via an AST transformer.
  Pure HTML output: works fine under noscript.
- Anchor links are opacity-0 until heading hover or keyboard focus.
diff --git a/render.go b/render.go
index 3fabbf6..0c62e3d 100644
--- a/render.go
+++ b/render.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"bytes"
+	_ "embed"
 	"fmt"
 	"html/template"
 	"net/http"
@@ -10,13 +11,24 @@ import (
 
 	"github.com/yuin/goldmark"
 	gmext "github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	gmutil "github.com/yuin/goldmark/util"
 )
 
 // maxRenderSize caps how much we'll read into memory for rendered views.
 // Bigger files still work fine via ?fmt=raw, which streams.
 const maxRenderSize = 4 << 20
 
-var markdown = goldmark.New(goldmark.WithExtensions(gmext.GFM))
+var markdown = goldmark.New(
+	goldmark.WithExtensions(gmext.GFM),
+	goldmark.WithParserOptions(
+		parser.WithAutoHeadingID(),
+		parser.WithASTTransformers(gmutil.Prioritized(headingAnchors{}, 100)),
+	),
+)
+
+//go:embed style.css
+var styleCSS []byte
 
 type dirEntry struct {
 	Name string
@@ -73,7 +85,9 @@ func serveRendered(w http.ResponseWriter, r *http.Request, root *os.Root, name,
 			return
 		}
 		var buf bytes.Buffer
-		if err := markdown.Convert(src, &buf); err != nil {
+		// A fresh IDs registry per document keeps duplicate-heading suffixes deterministic.
+		ctx := parser.NewContext(parser.WithIDs(newSlugIDs()))
+		if err := markdown.Convert(src, &buf, parser.WithContext(ctx)); err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
 		}
@@ -90,18 +104,7 @@ var pageTmpl = template.Must(template.New("page").Parse(`<!doctype html>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>{{.Title}}</title>
-<style>
-body { font-family: sans-serif; max-width: 56rem; margin: 1rem auto; padding: 0 1rem; }
-nav { border-bottom: 1px solid #ccc; padding-bottom: .5rem; margin-bottom: 1rem; }
-nav .raw { float: right; }
-ul.listing { list-style: none; padding-left: 0; }
-ul.listing li { padding: .1rem 0; }
-iframe.passthrough { width: 100%; height: 75vh; border: 1px solid #ccc; }
-article pre { background: #f4f4f4; padding: .5rem; overflow-x: auto; }
-article code { background: #f4f4f4; }
-article table { border-collapse: collapse; }
-article th, article td { border: 1px solid #ccc; padding: .2rem .5rem; }
-</style>
+<link rel="stylesheet" href="/-/style.css">
 </head>
 <body>
 <nav>
diff --git a/serve.go b/serve.go
index 8b88259..860de1e 100644
--- a/serve.go
+++ b/serve.go
@@ -34,6 +34,13 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		s.serveIndex(w, r)
 		return
 	}
+	// The "/-/" prefix is reserved for server-internal assets,
+	// keeping it distinct from any configured root name.
+	if r.URL.Path == "/-/style.css" {
+		w.Header().Set("Content-Type", "text/css; charset=utf-8")
+		w.Write(styleCSS)
+		return
+	}
 
 	name, rest, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/")
 	root, ok := s.trees[name]
diff --git a/serve_test.go b/serve_test.go
index 0f34748..2e2b21d 100644
--- a/serve_test.go
+++ b/serve_test.go
@@ -17,8 +17,8 @@ func newTestServer(t *testing.T) *server {
 		"README.md":     "# Hello\n\nsome *markdown* & <angle> stuff\n",
 		"sub/notes.txt": "plain text here\n",
 		"sub/deeper.md": "nested doc\n",
-		".git/config":   "should be hidden\n",
-	}
+		"dup.md":        "## Twice\n\n## Twice\n",
+		".git/config":   "should be hidden\n"}
 	for name, content := range files {
 		p := filepath.Join(dir, filepath.FromSlash(name))
 		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
diff --git a/slug.go b/slug.go
new file mode 100644
index 0000000..ac36e22
--- /dev/null
+++ b/slug.go
@@ -0,0 +1,84 @@
+package main
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/yuin/goldmark/ast"
+	"github.com/yuin/goldmark/parser"
+	"github.com/yuin/goldmark/text"
+)
+
+// slugify turns heading text into a URL fragment:
+// lowercase, alphanumerics kept, every other run of characters becomes one dash.
+func slugify(s string) string {
+	var b strings.Builder
+	dash := false
+	for _, r := range strings.ToLower(s) {
+		switch {
+		case r >= 'a' && r <= 'z' || r >= '0' && r <= '9':
+			if dash && b.Len() > 0 {
+				b.WriteByte('-')
+			}
+			dash = false
+			b.WriteRune(r)
+		default:
+			dash = true
+		}
+	}
+	return b.String()
+}
+
+// slugIDs implements parser.IDs so goldmark assigns our slugs as heading ids.
+// Duplicate headings get "-2", "-3", ... suffixes.
+type slugIDs struct {
+	used map[string]bool
+}
+
+func newSlugIDs() *slugIDs {
+	return &slugIDs{used: make(map[string]bool)}
+}
+
+func (s *slugIDs) Generate(value []byte, kind ast.NodeKind) []byte {
+	slug := slugify(string(value))
+	if slug == "" {
+		slug = "section"
+	}
+	candidate := slug
+	for n := 2; s.used[candidate]; n++ {
+		candidate = slug + "-" + strconv.Itoa(n)
+	}
+	s.used[candidate] = true
+	return []byte(candidate)
+}
+
+func (s *slugIDs) Put(value []byte) {
+	s.used[string(value)] = true
+}
+
+// headingAnchors is a goldmark AST transformer that appends a chainlink
+// anchor to every heading that received an id, linking to its own fragment.
+// Pure HTML output: works without javascript.
+type headingAnchors struct{}
+
+func (headingAnchors) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
+	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+		if !entering {
+			return ast.WalkContinue, nil
+		}
+		h, ok := n.(*ast.Heading)
+		if !ok {
+			return ast.WalkContinue, nil
+		}
+		id, ok := h.AttributeString("id")
+		if !ok {
+			return ast.WalkContinue, nil
+		}
+		link := ast.NewLink()
+		link.Destination = append([]byte("#"), id.([]byte)...)
+		link.SetAttributeString("class", []byte("anchor"))
+		link.AppendChild(link, ast.NewString([]byte("\U0001F517"))) // chainlink emoji
+		h.AppendChild(h, link)
+		return ast.WalkContinue, nil
+	})
+}
diff --git a/slug_test.go b/slug_test.go
new file mode 100644
index 0000000..55a0050
--- /dev/null
+++ b/slug_test.go
@@ -0,0 +1,50 @@
+package main
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestSlugify(t *testing.T) {
+	cases := map[string]string{
+		"What the verifier catches": "what-the-verifier-catches",
+		"Use before definition":     "use-before-definition",
+		"C++ / FFI (part 2)":        "c-ffi-part-2",
+		"  spaces  everywhere  ":    "spaces-everywhere",
+		"???":                       "",
+	}
+	for in, want := range cases {
+		if got := slugify(in); got != want {
+			t.Errorf("slugify(%q) = %q, want %q", in, got, want)
+		}
+	}
+}
+
+func TestHeadingAnchorsInRenderedPage(t *testing.T) {
+	srv := newTestServer(t)
+	rec := get(t, srv, "/demo/README.md")
+	body := rec.Body.String()
+	if !strings.Contains(body, `<h1 id="hello">`) {
+		t.Errorf("heading missing slug id; body:\n%s", body)
+	}
+	if !strings.Contains(body, `<a href="#hello" class="anchor">`) {
+		t.Errorf("heading missing chainlink anchor; body:\n%s", body)
+	}
+}
+
+func TestDuplicateHeadingsGetDistinctIDs(t *testing.T) {
+	srv := newTestServer(t)
+	// dup.md is written by newTestServer with two identical headings.
+	rec := get(t, srv, "/demo/dup.md")
+	body := rec.Body.String()
+	if !strings.Contains(body, `id="twice"`) || !strings.Contains(body, `id="twice-2"`) {
+		t.Errorf("duplicate headings not disambiguated; body:\n%s", body)
+	}
+}
+
+func TestStylesheetServed(t *testing.T) {
+	rec := get(t, newTestServer(t), "/-/style.css")
+	if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/css") {
+		t.Errorf("status = %d, content-type = %q", rec.Code, rec.Header().Get("Content-Type"))
+	}
+}
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..34676d3
--- /dev/null
+++ b/style.css
@@ -0,0 +1,102 @@
+/* repoview stylesheet.  Embedded in the binary; served at /-/style.css. */
+
+:root {
+	--ink: #24292f;
+	--ink-soft: #57606a;
+	--accent: #0a5fa4;
+	--rule: #d0d7de;
+	--paper: #fdfdfc;
+	--well: #f3f5f7;
+	--well-edge: #c9d1d9;
+}
+
+body {
+	font-family: sans-serif;
+	color: var(--ink);
+	background: var(--paper);
+	max-width: 56rem;
+	margin: 1rem auto;
+	padding: 0 1rem;
+	line-height: 1.5;
+}
+
+a { color: var(--accent); }
+
+nav {
+	border-bottom: 2px solid var(--accent);
+	padding-bottom: .5rem;
+	margin-bottom: 1rem;
+}
+nav .raw {
+	float: right;
+	font-size: .85rem;
+	border: 1px solid var(--rule);
+	border-radius: .25rem;
+	padding: .1rem .5rem;
+	text-decoration: none;
+}
+nav .raw:hover { background: var(--well); }
+
+ul.listing { list-style: none; padding-left: 1.5rem; }
+ul.listing li { padding: .15rem 0; }
+
+iframe.passthrough {
+	width: 100%;
+	height: 75vh;
+	border: 1px solid var(--well-edge);
+	border-radius: .25rem;
+	background: white;
+}
+
+/* Headings sit flush left; all other article content is inset,
+   so headings read as structure at a glance. */
+article > * { margin-left: 1.5rem; }
+article > h1, article > h2, article > h3,
+article > h4, article > h5, article > h6 { margin-left: 0; }
+
+article h1, article h2 {
+	border-bottom: 1px solid var(--rule);
+	padding-bottom: .2rem;
+}
+article h1 { color: var(--accent); }
+
+/* Chainlink anchors: subtle until the heading is hovered. */
+a.anchor {
+	font-size: .7em;
+	text-decoration: none;
+	margin-left: .4rem;
+	opacity: 0;
+}
+h1:hover a.anchor, h2:hover a.anchor, h3:hover a.anchor,
+h4:hover a.anchor, h5:hover a.anchor, h6:hover a.anchor,
+a.anchor:focus { opacity: .6; }
+
+/* Code blocks: inset further than prose, bordered, in a well. */
+article pre {
+	margin-left: 3rem;
+	background: var(--well);
+	border: 1px solid var(--well-edge);
+	border-left: 3px solid var(--accent);
+	border-radius: .25rem;
+	padding: .6rem .8rem;
+	overflow-x: auto;
+}
+article code {
+	background: var(--well);
+	border-radius: .2rem;
+	padding: .05rem .25rem;
+}
+article pre code { background: none; padding: 0; }
+
+article blockquote {
+	border-left: 3px solid var(--rule);
+	color: var(--ink-soft);
+	padding-left: .8rem;
+}
+
+article table { border-collapse: collapse; }
+article th, article td {
+	border: 1px solid var(--rule);
+	padding: .2rem .5rem;
+}
+article th { background: var(--well); }