diff --git a/go.mod b/go.mod
index 6a43339..0c00c4b 100644
--- a/go.mod
+++ b/go.mod
@@ -3,11 +3,15 @@ module github.com/atmin/gitmote
go 1.26.1
require (
+ github.com/alecthomas/chroma/v2 v2.27.0
github.com/atmin/s3lite v0.1.0
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.27
github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.13
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2
+ github.com/microcosm-cc/bluemonday v1.0.27
+ github.com/yuin/goldmark v1.8.2
+ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
)
require (
@@ -27,11 +31,14 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
+ github.com/aymerick/douceur v0.2.0 // indirect
github.com/benbjohnson/litestream v0.5.13 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/css v1.0.1 // indirect
github.com/hablullah/go-hijri v1.0.2 // indirect
github.com/hablullah/go-juliandays v1.0.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -53,6 +60,7 @@ require (
github.com/tetratelabs/wazero v1.2.1 // indirect
github.com/wasilibs/go-re2 v1.3.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
+ golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
diff --git a/go.sum b/go.sum
index e24f6d8..c0f6503 100644
--- a/go.sum
+++ b/go.sum
@@ -22,6 +22,14 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJ
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
+github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
+github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
+github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0 h1:wQlqotpyjYPjJz+Noh5bRu7Snmydk8SKC5Z6u1CR20Y=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.3.0/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/atmin/s3lite v0.1.0 h1:RNnJWx0D7gSjZfr5OkqosV4wMi68N/bj5PY8IKPEmWg=
@@ -66,6 +74,8 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.43.5 h1:T3ANO8QWDbzQD8f4+UaX+fvJlyGn
github.com/aws/aws-sdk-go-v2/service/sts v1.43.5/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/litestream v0.5.13 h1:LJvJFlhZLXuejV+gNSiOI/AJeYEmU2hUjZkjqWky2lE=
github.com/benbjohnson/litestream v0.5.13/go.mod h1:aN5OtdkcCz07xVjCbmMWOGUSIdPumpqBYtdyrzNMhOI=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -84,10 +94,15 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
+github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -123,12 +138,16 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gT
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas=
github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU=
+github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
+github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
@@ -154,6 +173,8 @@ github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbW
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
+github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
+github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
@@ -210,6 +231,8 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/studio-b12/gowebdav v0.11.0 h1:qbQzq4USxY28ZYsGJUfO5jR+xkFtcnwWgitp4Zp1irU=
@@ -230,6 +253,11 @@ github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
+github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
+github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
@@ -280,6 +308,8 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
diff --git a/internal/render/render.go b/internal/render/render.go
new file mode 100644
index 0000000..b66e15e
--- /dev/null
+++ b/internal/render/render.go
@@ -0,0 +1,132 @@
+// Package render turns untrusted repo content into safe HTML for the browse UI:
+// syntax-highlighted source (chroma) and rendered markdown (goldmark). It is
+// deliberately separate from webui so the security-critical rendering — every
+// byte here originates from a repo and is shown to an admin — is unit-testable
+// in isolation.
+//
+// Two defenses guard the markdown path (task 15 safety): goldmark escapes raw
+// HTML by default (WithUnsafe is never set), and bluemonday's UGCPolicy strips
+// what slips through — scripts, event-handler attributes, and javascript:/data:
+// URLs. chroma's class-based output is static markup (no scripts) and its
+// stylesheet is generated, not repo-derived, so both are safe to embed.
+package render
+
+import (
+ "bytes"
+ "html/template"
+ "path"
+ "strings"
+ "sync"
+
+ chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+ "github.com/alecthomas/chroma/v2/lexers"
+ "github.com/alecthomas/chroma/v2/styles"
+ "github.com/microcosm-cc/bluemonday"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+
+ highlighting "github.com/yuin/goldmark-highlighting/v2"
+)
+
+// MaxSize caps the input render will highlight or markdown-render. Above it the
+// caller falls back to a plain <pre>: a minified or generated blob shouldn't
+// turn into pathological HTML.
+const MaxSize = 512 * 1024
+
+// highlightStyle is the chroma theme shared by blob view and README code
+// blocks, so the two match. Class-based output pairs it with HighlightCSS.
+var highlightStyle = styles.Get("github")
+
+// classFormatter emits class-based (not inline-style) highlight markup, so a
+// single stylesheet from HighlightCSS covers every page.
+var classFormatter = chromahtml.New(chromahtml.WithClasses(true), chromahtml.TabWidth(4))
+
+// Highlight renders code as syntax-highlighted, class-based HTML wrapped in the
+// formatter's <pre>. The lexer is chosen by filename, then by content analysis,
+// then plain text. Highlighting is best-effort: on any lexer/format error the
+// caller falls back to a plain <pre>.
+func Highlight(code []byte, filename string) (template.HTML, error) {
+ lexer := lexers.Match(path.Base(filename))
+ if lexer == nil {
+ lexer = lexers.Analyse(string(code))
+ }
+ if lexer == nil {
+ lexer = lexers.Fallback
+ }
+ it, err := lexer.Tokenise(nil, string(code))
+ if err != nil {
+ return "", err
+ }
+ var buf bytes.Buffer
+ if err := classFormatter.Format(&buf, highlightStyle, it); err != nil {
+ return "", err
+ }
+ return template.HTML(buf.String()), nil //nolint:gosec // chroma class output is static markup
+}
+
+var highlightCSS struct {
+ sync.Once
+ css template.CSS
+}
+
+// HighlightCSS is the stylesheet paired with Highlight's class-based output, for
+// the layout to include once. It is generated by chroma from a fixed theme —
+// trusted, not repo-derived — and cached after the first build.
+func HighlightCSS() template.CSS {
+ highlightCSS.Do(func() {
+ var buf bytes.Buffer
+ _ = classFormatter.WriteCSS(&buf, highlightStyle)
+ highlightCSS.css = template.CSS(buf.String())
+ })
+ return highlightCSS.css
+}
+
+// markdown renders CommonMark + GFM. Raw HTML is escaped (goldmark's default,
+// WithUnsafe intentionally unset); fenced code is routed through chroma with the
+// same class-based theme as blob view.
+var markdown = goldmark.New(
+ goldmark.WithExtensions(
+ extension.GFM,
+ highlighting.NewHighlighting(
+ highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
+ ),
+ ),
+)
+
+// sanitizer strips dangerous constructs goldmark passes through, while allowing
+// the class attribute chroma needs on the code elements it emits (so README
+// code stays highlighted). UGCPolicy already forbids scripts, event handlers,
+// and javascript:/data: URL schemes.
+var sanitizer = func() *bluemonday.Policy {
+ p := bluemonday.UGCPolicy()
+ p.AllowAttrs("class").OnElements("div", "pre", "code", "span")
+ return p
+}()
+
+// Markdown renders src to sanitized HTML. It never returns unsafe markup: even
+// if goldmark errored, the (empty) output passes through the sanitizer.
+func Markdown(src []byte) template.HTML {
+ var buf bytes.Buffer
+ if err := markdown.Convert(src, &buf); err != nil {
+ return ""
+ }
+ return template.HTML(sanitizer.SanitizeBytes(buf.Bytes())) //nolint:gosec // sanitized above
+}
+
+// IsMarkdown reports whether filename names a markdown file (.md/.markdown,
+// case-insensitive).
+func IsMarkdown(filename string) bool {
+ switch strings.ToLower(path.Ext(filename)) {
+ case ".md", ".markdown":
+ return true
+ default:
+ return false
+ }
+}
+
+// IsReadme reports whether name is a README markdown file (README.md /
+// README.markdown, case-insensitive) — the file rendered below a tree listing.
+func IsReadme(name string) bool {
+ lower := strings.ToLower(name)
+ return lower == "readme.md" || lower == "readme.markdown"
+}
diff --git a/internal/render/render_test.go b/internal/render/render_test.go
new file mode 100644
index 0000000..ee9fa55
--- /dev/null
+++ b/internal/render/render_test.go
@@ -0,0 +1,96 @@
+package render
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestHighlightByExtension(t *testing.T) {
+ out, err := Highlight([]byte("package main\n\nfunc main() {}\n"), "main.go")
+ if err != nil {
+ t.Fatalf("Highlight: %v", err)
+ }
+ s := string(out)
+ // Class-based output: keywords wrapped in class spans, no inline styles.
+ if !strings.Contains(s, "<pre") || !strings.Contains(s, `class="`) {
+ t.Fatalf("go highlight missing class spans:\n%s", s)
+ }
+ if strings.Contains(s, "style=") {
+ t.Fatalf("expected class-based output, got inline styles:\n%s", s)
+ }
+}
+
+func TestHighlightUnknownExtensionFallsBack(t *testing.T) {
+ out, err := Highlight([]byte("just some text\n"), "notes.zzz")
+ if err != nil {
+ t.Fatalf("Highlight: %v", err)
+ }
+ if !strings.Contains(string(out), "just some text") {
+ t.Fatalf("fallback dropped content:\n%s", out)
+ }
+}
+
+func TestHighlightEmpty(t *testing.T) {
+ out, err := Highlight(nil, "empty.go")
+ if err != nil {
+ t.Fatalf("Highlight(empty): %v", err)
+ }
+ if !strings.Contains(string(out), "<pre") {
+ t.Fatalf("empty input produced no <pre>:\n%s", out)
+ }
+}
+
+func TestHighlightCSS(t *testing.T) {
+ css := string(HighlightCSS())
+ if css == "" || !strings.Contains(css, ".chroma") {
+ t.Fatalf("HighlightCSS looks wrong:\n%s", css)
+ }
+}
+
+func TestMarkdownGFM(t *testing.T) {
+ src := []byte("# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n```go\nfunc main() {}\n```\n")
+ out := string(Markdown(src))
+ if !strings.Contains(out, "<h1") {
+ t.Fatalf("heading not rendered:\n%s", out)
+ }
+ if !strings.Contains(out, "<table") {
+ t.Fatalf("GFM table not rendered:\n%s", out)
+ }
+ // Fenced code highlighted via goldmark-highlighting → chroma class spans.
+ if !strings.Contains(out, `class="`) {
+ t.Fatalf("fenced code not highlighted:\n%s", out)
+ }
+}
+
+func TestMarkdownSanitizesXSS(t *testing.T) {
+ src := []byte("# Hi\n\n<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n[click](javascript:alert(1))\n")
+ out := string(Markdown(src))
+ if strings.Contains(out, "<script") {
+ t.Fatalf("script tag survived sanitization:\n%s", out)
+ }
+ if strings.Contains(out, "onerror") {
+ t.Fatalf("event handler survived sanitization:\n%s", out)
+ }
+ if strings.Contains(strings.ToLower(out), "javascript:") {
+ t.Fatalf("javascript: URL survived sanitization:\n%s", out)
+ }
+}
+
+func TestIsMarkdownAndReadme(t *testing.T) {
+ for _, name := range []string{"a.md", "A.MARKDOWN", "docs/x.Md"} {
+ if !IsMarkdown(name) {
+ t.Fatalf("IsMarkdown(%q) = false", name)
+ }
+ }
+ if IsMarkdown("main.go") {
+ t.Fatal("IsMarkdown(main.go) = true")
+ }
+ for _, name := range []string{"README.md", "readme.markdown", "ReadMe.MD"} {
+ if !IsReadme(name) {
+ t.Fatalf("IsReadme(%q) = false", name)
+ }
+ }
+ if IsReadme("readme.txt") || IsReadme("CONTRIBUTING.md") {
+ t.Fatal("IsReadme matched a non-README")
+ }
+}
diff --git a/internal/webui/browse.go b/internal/webui/browse.go
index 1a74df2..2170bce 100644
--- a/internal/webui/browse.go
+++ b/internal/webui/browse.go
@@ -2,6 +2,7 @@ package webui
import (
"errors"
+ "html/template"
"io"
"mime"
"net/http"
@@ -9,6 +10,7 @@ import (
"strings"
"github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/render"
"github.com/atmin/gitmote/internal/repo"
)
@@ -142,6 +144,7 @@ func (h *Handler) browseTree(w http.ResponseWriter, r *http.Request, repoName, t
Path: treePath,
Crumbs: crumbs(treePath),
Entries: entries,
+ Readme: h.readme(r, c, entries),
})
}
@@ -166,8 +169,21 @@ func (h *Handler) browseBlob(w http.ResponseWriter, r *http.Request, repoName, b
Binary: binary,
Size: size,
}
- if !binary {
+ // Text under the size cap is rendered: markdown for a .md blob, otherwise
+ // syntax-highlighted source. Anything else (binary, oversize, or a
+ // highlight error) falls back to the plain <pre> on .Text.
+ switch {
+ case binary:
+ case size > render.MaxSize:
data.Text = string(content)
+ case render.IsMarkdown(blobPath):
+ data.Rendered = render.Markdown(content)
+ default:
+ if hl, err := render.Highlight(content, blobPath); err == nil {
+ data.Highlighted = hl
+ } else {
+ data.Text = string(content)
+ }
}
h.render(w, "browse_blob.html", data)
}
@@ -250,6 +266,23 @@ func (h *Handler) browseCommit(w http.ResponseWriter, r *http.Request, repoName,
})
}
+// readme renders a directory's README.md (if any) to HTML for display below
+// the tree listing. It is best-effort: a missing, binary, oversize, or
+// unreadable README simply yields no rendered block.
+func (h *Handler) readme(r *http.Request, c browseCtx, entries []repo.TreeEntry) template.HTML {
+ for _, e := range entries {
+ if e.Type != "blob" || !render.IsReadme(e.Name) {
+ continue
+ }
+ content, size, binary, err := repo.Blob(r.Context(), c.dir, c.sha, e.Path)
+ if err != nil || binary || size > render.MaxSize {
+ return ""
+ }
+ return render.Markdown(content)
+ }
+ return ""
+}
+
// crumbs turns a path into its breadcrumb trail, each segment linking to the
// tree at that depth. The empty path yields no crumbs (the repo root link in
// the template stands alone).
diff --git a/internal/webui/browse_test.go b/internal/webui/browse_test.go
index ae3ac4e..67b2f54 100644
--- a/internal/webui/browse_test.go
+++ b/internal/webui/browse_test.go
@@ -40,8 +40,10 @@ func (x *harness) seedBrowseRepo(name, branch string) (head, first string) {
src := x.t.TempDir()
gitTest(x.t, src, "init", "-b", branch, ".")
write(x.t, src, "hello.go", "package main\n\nfunc main() {}\n")
+ write(x.t, src, "README.md", "# App\n\nHello **world**.\n\n```go\nfunc main() {}\n```\n")
write(x.t, src, "sub/note.txt", "nested\n")
write(x.t, src, "bin.dat", "a\x00b\x00c")
+ write(x.t, src, "big.go", "package main\n"+strings.Repeat("// filler line\n", 40000))
gitTest(x.t, src, "add", "-A")
gitTest(x.t, src, "commit", "-m", "first")
first = gitTest(x.t, src, "rev-parse", "HEAD")
@@ -129,12 +131,13 @@ func TestBrowseTreeAndBlob(t *testing.T) {
t.Fatalf("sub tree = %d (%s)", rec.Code, rec.Body)
}
- // Text blob renders inside a <pre>.
+ // Text blob renders its content (highlighting splits tokens into spans, so
+ // assert on a single token that survives).
rec = x.do(http.MethodGet, "/browse/alice/app/-/blob/hello.go?ref=main", nil, session)
if rec.Code != http.StatusOK {
t.Fatalf("blob = %d (%s)", rec.Code, rec.Body)
}
- if !strings.Contains(rec.Body.String(), "package main") {
+ if !strings.Contains(rec.Body.String(), "package") {
t.Fatalf("blob body missing content:\n%s", rec.Body)
}
@@ -201,6 +204,54 @@ func TestBrowseCommitsAndCommit(t *testing.T) {
}
}
+func TestBrowseHighlightAndMarkdown(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("alice/app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ // A .go blob is syntax-highlighted: chroma class spans, not a bare <pre>.
+ rec := x.do(http.MethodGet, "/browse/alice/app/-/blob/hello.go?ref=main", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("go blob = %d (%s)", rec.Code, rec.Body)
+ }
+ if !strings.Contains(rec.Body.String(), `class="`) || !strings.Contains(rec.Body.String(), "chroma") {
+ t.Fatalf("go blob not highlighted:\n%s", rec.Body)
+ }
+
+ // A .md blob renders markdown in place of highlighted source.
+ rec = x.do(http.MethodGet, "/browse/alice/app/-/blob/README.md?ref=main", nil, session)
+ if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "<h1") {
+ t.Fatalf("md blob not rendered:\n%s", rec.Body)
+ }
+
+ // The tree page renders the directory's README below the listing.
+ rec = x.do(http.MethodGet, "/browse/alice/app/-/tree/", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("tree = %d", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "markdown-body") || !strings.Contains(rec.Body.String(), "<h1") {
+ t.Fatalf("tree missing rendered README:\n%s", rec.Body)
+ }
+}
+
+func TestBrowseBlobSizeGuard(t *testing.T) {
+ x := newHarness(t)
+ x.seedBrowseRepo("alice/app", "main")
+ session := x.login(x.mintTokenFor(x.admin.ID))
+
+ // A blob over the cap falls back to a plain <pre>, no chroma markup.
+ rec := x.do(http.MethodGet, "/browse/alice/app/-/blob/big.go?ref=main", nil, session)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("big blob = %d", rec.Code)
+ }
+ if strings.Contains(rec.Body.String(), `class="chroma"`) {
+ t.Fatalf("oversize blob was highlighted; expected plain <pre>")
+ }
+ if !strings.Contains(rec.Body.String(), "<pre>package main") {
+ t.Fatalf("oversize blob not shown as plain pre:\n%s", rec.Body.String()[:200])
+ }
+}
+
func TestBrowseNotFoundAndTraversal(t *testing.T) {
x := newHarness(t)
x.seedBrowseRepo("alice/app", "main")
diff --git a/internal/webui/data.go b/internal/webui/data.go
index 56fe8a0..c6a7f2f 100644
--- a/internal/webui/data.go
+++ b/internal/webui/data.go
@@ -1,6 +1,8 @@
package webui
import (
+ "html/template"
+
"github.com/atmin/gitmote/internal/meta"
"github.com/atmin/gitmote/internal/repo"
)
@@ -72,15 +74,18 @@ type treeData struct {
Path string
Crumbs []crumb
Entries []repo.TreeEntry
+ Readme template.HTML // rendered README.md for this dir, if present
}
type blobData struct {
browseBase
- Path string
- Crumbs []crumb
- Text string // decoded text content (text branch); empty for binary
- Binary bool
- Size int64
+ Path string
+ Crumbs []crumb
+ Text string // decoded text content; the plain-<pre> fallback
+ Highlighted template.HTML // syntax-highlighted source, when available
+ Rendered template.HTML // rendered markdown, for a .md blob
+ Binary bool
+ Size int64
}
type commitsData struct {
diff --git a/internal/webui/templates/browse_blob.html b/internal/webui/templates/browse_blob.html
index 9d60f81..e89b5c7 100644
--- a/internal/webui/templates/browse_blob.html
+++ b/internal/webui/templates/browse_blob.html
@@ -3,6 +3,10 @@
<p>{{.Size}} bytes · <a href="/browse/{{.Repo}}/-/raw/{{.Path}}?ref={{.Ref}}">raw</a></p>
{{if .Binary}}
<p>Binary file — <a href="/browse/{{.Repo}}/-/raw/{{.Path}}?ref={{.Ref}}">download</a>.</p>
+{{else if .Rendered}}
+<div class="markdown-body">{{.Rendered}}</div>
+{{else if .Highlighted}}
+{{.Highlighted}}
{{else}}
<pre>{{.Text}}</pre>
{{end}}
diff --git a/internal/webui/templates/browse_tree.html b/internal/webui/templates/browse_tree.html
index 2596615..a512b98 100644
--- a/internal/webui/templates/browse_tree.html
+++ b/internal/webui/templates/browse_tree.html
@@ -23,4 +23,5 @@
{{end}}
</tbody>
</table>
+{{if .Readme}}<div class="markdown-body">{{.Readme}}</div>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/webui/templates/layout.html b/internal/webui/templates/layout.html
index c0d2b73..3cd53b3 100644
--- a/internal/webui/templates/layout.html
+++ b/internal/webui/templates/layout.html
@@ -21,6 +21,10 @@
.flash.ok { background: #1a7f371a; }
.flash.err { background: #cc00001a; }
code { word-break: break-all; }
+ pre { overflow-x: auto; padding: .75rem; background: #8881; border-radius: 6px; }
+ .chroma { padding: .75rem; border-radius: 6px; overflow-x: auto; }
+ .markdown-body { border-top: 1px solid #8884; margin-top: 1.5rem; padding-top: 1rem; }
+ {{highlightCSS}}
</style>
</head>
<body>
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index 1bbdc84..06fa4eb 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -25,6 +25,7 @@ import (
"github.com/atmin/gitmote/internal/auth"
"github.com/atmin/gitmote/internal/meta"
+ "github.com/atmin/gitmote/internal/render"
"github.com/atmin/gitmote/internal/repo"
)
@@ -74,6 +75,9 @@ func New(md *meta.Metadata, mz *repo.Materializer, a Authenticator, cookieKey []
}
return ""
},
+ // highlightCSS is chroma's class stylesheet, included once in the head so
+ // highlighted blobs and README code blocks share one theme.
+ "highlightCSS": render.HighlightCSS,
}).ParseFS(templatesFS, "templates/*.html")
if err != nil {
return nil, err
diff --git a/tasks/14-browse-repo.md b/tasks/14-browse-repo.md
deleted file mode 100644
index 185e4b1..0000000
--- a/tasks/14-browse-repo.md
+++ /dev/null
@@ -1,124 +0,0 @@
-# Browse a repo in the web UI
-
-## Spec
-
-Read-only repo browsing in the existing web UI, admin-session gated (same gate as
-the rest of `/ui`). At an arbitrary ref (branch, tag, or hash) a user can:
-
-- **Tree** — list a directory (name, type, mode, size).
-- **Blob** — view a file's raw contents; download it raw.
-- **Commits** — the commit log for a ref (optionally scoped to a path).
-- **Commit** — a single commit with its diff.
-- **Ref switcher** — pick among the repo's branches/tags.
-
-Deliberately **out of scope** (later, if the lack is felt): syntax highlighting,
-markdown/README rendering, blame, search, line-level permalinks, submodule follow.
-Everything here is cheap `git` plumbing against the already-materialized repo — no
-new storage, no new object-hydration work.
-
-## Current
-
-The web UI ([internal/webui/webui.go](../internal/webui/webui.go)) is
-admin-management only — repos, users, tokens, ACLs — all behind
-[`requireAdmin`](../internal/webui/webui.go#L113). Its `Handler` holds a
-`*meta.Metadata` but **no `*repo.Materializer`**, so it can't reach objects.
-
-Object access already exists on the git path:
-[`Materializer.Materialize(ctx, name)`](../internal/repo/materialize.go#L49)
-full-hydrates the bare repo (refs from s3lite, every object from the store) and
-returns its on-disk dir — this runs in prod on every clone. After it runs, the
-dir is an ordinary bare repo, readable with git plumbing. The package's git
-exec helper [`runGit`](../internal/repo/materialize.go#L168) only returns an
-error, though — it does **not** capture stdout, which browsing needs.
-
-Refs/branches are already listable without git:
-[`meta.ListRefs(repoID)`](../internal/meta/refs.go#L45) → `[]meta.Ref`,
-[`GetRepo`](../internal/meta/repos.go#L55) gives `DefaultBranch`.
-
-## Change
-
-**1. A read API in the `repo` package** — a small reader over a materialized
-dir, backed by a new stdout-capturing git exec helper (sibling to `runGit`).
-Methods, each a thin plumbing call:
-
-- `ResolveRef(ctx, dir, ref) (sha string, err error)` — `git rev-parse --verify
- <ref>^{commit}`. **Reject a `ref` starting with `-`** (flag injection) and
- return a not-found sentinel on unknown ref.
-- `Tree(ctx, dir, sha, path) ([]TreeEntry, err)` — `git ls-tree --long <sha> --
- <path>`; entry = {mode, type (blob/tree), sha, size, name}.
-- `Blob(ctx, dir, sha, path) (content []byte, size int64, binary bool, err)` —
- `cat-file -s` for size, stream `cat-file blob <sha>:<path>`; flag binary by a
- NUL-byte sniff of the leading bytes.
-- `Log(ctx, dir, sha, path string, limit int) ([]Commit, err)` — `git log
- --format=<record-sep> -n <limit+1> <sha> -- <path>`; report + drop the `+1`
- overflow as a "more" marker (**no silent truncation** — surface it in the UI).
-- `Show(ctx, dir, sha) (Commit, diff string, err)` — `git show --format=…
- --patch <sha>` (use `--root` so the initial commit diffs).
-
-Branches/tags for the switcher come from `meta.ListRefs` — no git needed.
-
-**2. Wire the `Materializer` into the webui `Handler`** — add the dep to the
-struct and `New(...)`; the caller in [cmd/gitmote/main.go](../cmd/gitmote/main.go)
-already builds one for githttp, pass the same instance.
-
-**3. Routes + a manual path split.** Repo names contain slashes
-(`atmin/atmin.net`) and Go's `{repo...}` wildcard must be the final segment, so it
-can't carry a suffix. Mirror [`parseGitPath`](../internal/githttp/backend.go#L298):
-register one subtree handler and split manually on a `/-/` marker (GitLab's
-convention) into `repoName` and an action tail. **`ref` is a query parameter**
-(defaults to `DefaultBranch`) so a slashed branch (`feature/x`) needs no
-disambiguation from the path:
-
-```
-GET /browse/{repo}/-/tree/{path}?ref=… dir listing
-GET /browse/{repo}/-/blob/{path}?ref=… file view
-GET /browse/{repo}/-/raw/{path}?ref=… raw bytes (Content-Disposition, sniffed type)
-GET /browse/{repo}/-/commits?ref=…&path=… log
-GET /browse/{repo}/-/commit/{sha} single commit + diff
-```
-
-All behind `requireAdmin`. Each handler: `Materialize(repo)` → `ResolveRef` →
-the relevant reader call → render. `meta.ErrNotFound` (repo) and an
-unknown-ref/path → 404.
-
-**4. Templates** (reuse [layout.html](../internal/webui/templates/layout.html),
-plain HTML, no highlighting): `browse_tree.html` (breadcrumb + ref switcher +
-entry table, dirs and blobs linked), `browse_blob.html` (metadata + `<pre>` for
-text / "binary — download" for binary + raw link), `browse_commits.html` (log
-rows linking to each commit, "more" marker when capped), `browse_commit.html`
-(commit metadata + `<pre>` diff).
-
-Keep the blob template's text branch a **single, isolated render block** (the
-bare `<pre>{{...}}</pre>`) so [task 15](15-highlight-and-markdown.md) can swap it
-for highlighted / rendered HTML without touching the reader API or routing — that
-task is purely additive on top of this seam.
-
-**Safety / notes:**
-
-- **Path traversal / injection:** repo name is already validated by
- `repoDir`; additionally reject a `path` that is absolute or contains `..`, and
- a `ref`/`sha` starting with `-`. Always pass `--` before pathspecs.
-- **Any hash browses:** `hydrateObjects` copies the *whole* store prefix, so
- every object is present regardless of reachability — an arbitrary valid hash
- resolves. (A hash for objects never pushed simply 404s.)
-- **Cost:** browse full-hydrates like a clone — fine at current scale, same
- policy as everywhere else (object-hydration.md); not a regression.
-- **Raw endpoint** sets `Content-Type` from a sniff and
- `Content-Disposition: attachment`; it streams, it does not buffer the whole
- blob.
-
-## Verify
-
-- **Reader unit tests** (`internal/repo`): build a fixture bare repo in-test
- (a couple commits, a subdir, a binary blob, a tag), then assert `ResolveRef`
- (branch, tag, hash, and a `-`-prefixed ref → rejected), `Tree` entries,
- `Blob` (text vs binary flag + size), `Log` (ordering + the "more" overflow
- marker at the cap), `Show` (diff present, initial commit diffs via `--root`).
-- **webui route tests** — golden **and** failure paths per
- [CONTRIBUTING](../CONTRIBUTING.md): unauth request → redirected/denied by
- `requireAdmin`; tree/blob/commits/commit render for a materialized repo; ref
- switcher lists `meta.ListRefs`; raw download has the attachment header and
- correct bytes; unknown repo → 404, unknown ref → 404, `..` in path → rejected;
- binary blob renders the download affordance, not garbage.
-- **Regression:** `make e2e-local` + `make e2e-restore` stay green (browse
- doesn't touch the write path); `gofmt`/`golangci-lint`/`go test ./...` clean.
diff --git a/tasks/15-highlight-and-markdown.md b/tasks/15-highlight-and-markdown.md
deleted file mode 100644
index a42f311..0000000
--- a/tasks/15-highlight-and-markdown.md
+++ /dev/null
@@ -1,99 +0,0 @@
-# Syntax highlighting and markdown rendering in the browse UI
-
-## Spec
-
-Layer two additive niceties onto read-only repo browsing
-([task 14](14-browse-repo.md)), keeping the same admin-session gate:
-
-- **Syntax highlighting** — a text blob is shown with per-language highlighting
- instead of a bare `<pre>`. Language is chosen from the file's path/extension,
- falling back to plain text when unknown.
-- **Markdown rendering** — when a directory holds a `README.md` (case-insensitive,
- `.md`/`.markdown`), render it below the tree listing as HTML. A `.md` blob view
- offers the same rendered view (raw still one click away via the existing raw
- link).
-
-This is the Hugo stack: **goldmark** for markdown, **chroma** for code, wired
-together so fenced code inside a README highlights with the same theme as blob
-view.
-
-**Out of scope** (still): blame, search, line permalinks, math/mermaid, image
-rendering from the tree, diff highlighting in `browse_commit.html` (the commit
-diff stays a plain `<pre>` — revisit only if the lack is felt).
-
-## Current
-
-Task 14 lands plain browsing: `browse_blob.html` shows a text blob as a bare
-`<pre>`, and the tree page has no README. The blob text branch is deliberately a
-single isolated render block (task 14 §4) — this task swaps it. No syntax/markdown
-dependencies exist yet ([go.mod](../go.mod) has none).
-
-## Change
-
-**Dependencies** (all pure Go, no cgo — same posture as the rest of the tree):
-
-- `github.com/alecthomas/chroma/v2` — syntax highlighter (Hugo's engine, 250+
- lexers).
-- `github.com/yuin/goldmark` (+ its GFM extension) — CommonMark markdown.
-- `github.com/yuin/goldmark-highlighting/v2` — routes goldmark fenced code
- through chroma, so README code blocks match blob view.
-- `github.com/microcosm-cc/bluemonday` — HTML sanitizer for the rendered
- markdown output (see Safety).
-
-**1. A render package** (e.g. `internal/render`) so webui stays thin and this
-logic is unit-testable in isolation:
-
-- `Highlight(code []byte, filename string) (template.HTML, error)` — pick a
- chroma lexer by `filename` (fallback: analyse/plain), format with the
- **class-based** HTML formatter (not inline styles), return the `<pre>`-wrapped
- markup. Emit the paired stylesheet **once** via `HighlightCSS() template.CSS`
- for the layout to include (see §3). On any lexer/format error, the caller falls
- back to the plain `<pre>` — highlighting is best-effort, never fatal.
-- `Markdown(src []byte) template.HTML` — goldmark (GFM on) → **bluemonday
- UGCPolicy** sanitize → `template.HTML`. goldmark runs with **raw HTML escaped**
- (its default; do *not* set `html.WithUnsafe()`).
-
-**2. Size guard.** Skip highlighting/markdown above a cap (e.g. 512 KiB) and fall
-back to plain `<pre>` — a minified/huge blob shouldn't turn into pathological
-HTML. Surface nothing special in the UI beyond the plain view; a one-line note
-("shown unhighlighted — too large") is fine.
-
-**3. Wire into webui.** Blob handler: for a text blob under the cap, call
-`Highlight`; on error/oversize, keep the plain `<pre>`. Tree handler: after
-listing, if a README blob exists in the directory, `Markdown` it and pass the
-rendered HTML to `browse_tree.html`. Add chroma's stylesheet to
-[layout.html](../internal/webui/templates/layout.html)'s `<style>` head (or a
-`browse`-scoped `<style>`), from `render.HighlightCSS()`. The `.md` blob view
-renders markdown in place of the highlighted source, with a toggle/link to raw.
-
-**Safety:**
-
-- **XSS is the headline risk** — READMEs and file contents are untrusted repo
- content, and browse is admin-gated, so a poisoned README rendered for an admin
- is a session-theft vector. Two layers: goldmark escapes raw HTML by default
- (keep it that way), **and** bluemonday UGCPolicy strips dangerous constructs
- goldmark passes through — notably `javascript:`/`data:` URLs in links. The
- sanitize step is mandatory, not optional.
-- Chroma's class-based output is static markup (no scripts), safe to embed; the
- generated stylesheet is trusted (from chroma, not from repo content).
-
-## Verify
-
-- **Render unit tests** (`internal/render`):
- - `Highlight` picks a lexer by extension (e.g. `.go` → keywords wrapped in
- class spans), falls back to plain for an unknown extension, and returns a
- usable `<pre>` for empty input.
- - `Markdown` renders CommonMark + GFM (a table, a fenced code block that comes
- out highlighted via goldmark-highlighting).
- - **Sanitization (failure path):** a README containing `<script>…</script>`,
- an `onerror=` attribute, and a `[x](javascript:alert(1))` link renders with
- **all three neutralised** — assert the script/handler/`javascript:` scheme
- are absent from the output. This is the safety invariant; it gets an explicit
- test (CONTRIBUTING → test everything).
- - Size guard: input over the cap returns the plain-`<pre>` path.
-- **webui route tests:** a `.go` blob view contains highlight class spans; a
- directory with a `README.md` shows rendered HTML on the tree page; a `.md` blob
- view renders markdown; a blob over the cap falls back to plain `<pre>`. Golden
- and failure paths, still behind `requireAdmin`.
-- **Regression:** `make e2e-local` + `make e2e-restore` stay green (no write-path
- or protocol change); `gofmt`/`golangci-lint`/`go test ./...` clean.
diff --git a/tasks/README.md b/tasks/README.md
index e40d825..72b4564 100644
--- a/tasks/README.md
+++ b/tasks/README.md
@@ -12,10 +12,4 @@ starts (litestream restore), and enforces the single writer by a lease (s3lite
`RoleAuto`), so rolling deploys are safe by construction. Speculative directions
live in [../docs/evolution/](../docs/evolution/).
-- [14-browse-repo.md](14-browse-repo.md) — read-only repo browsing in the web UI
- (admin-gated): tree, blob, raw download, commit log, single-commit diff, ref
- switcher — all cheap `git` plumbing over the already-materialized repo. No
- syntax highlighting / markdown rendering yet.
-- [15-highlight-and-markdown.md](15-highlight-and-markdown.md) — layer syntax
- highlighting (chroma) and markdown/README rendering (goldmark + bluemonday
- sanitize) onto the browse UI. Purely additive on task 14's blob/tree templates.
+_No active tasks — the frontier is clear. Add the next unit of work here._