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); }