Commit 9d8fd17820
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
e2e/web_test.go +1 −1
| @@ -85,7 +85,7 @@ func TestWebUI(t *testing.T) { | |||
| 85 | if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") { | 85 | if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") { |
| 86 | t.Fatalf("repo home: %d\n%s", status, body) | 86 | t.Fatalf("repo home: %d\n%s", status, body) |
| 87 | } | 87 | } |
| 88 | if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { | 88 | if !strings.Contains(body, "<h1 id=\"hello-site\">hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { |
| 89 | t.Fatalf("README not rendered:\n%s", body) | 89 | t.Fatalf("README not rendered:\n%s", body) |
| 90 | } | 90 | } |
| 91 | for _, tab := range []string{">Issues<", ">Merge requests<"} { | 91 | for _, tab := range []string{">Issues<", ">Merge requests<"} { |
internal/httpd/anchors_test.go added +40
| @@ -0,0 +1,40 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "net/http/httptest" | ||
| 6 | "strings" | ||
| 7 | "testing" | ||
| 8 | |||
| 9 | "gitbay.org/gitbay/internal/config" | ||
| 10 | ) | ||
| 11 | |||
| 12 | // Markdown headings carry ids, so a README section can be deep-linked | ||
| 13 | // the way org headings already could (#132). | ||
| 14 | func TestMarkdownHeadingAnchors(t *testing.T) { | ||
| 15 | out := string(renderReadme("README.md", []byte("# Getting started\n\n## Two words\n\n- [ ] a task\n"))) | ||
| 16 | for _, want := range []string{`id="getting-started"`, `id="two-words"`, `type="checkbox"`} { | ||
| 17 | if !strings.Contains(out, want) { | ||
| 18 | t.Errorf("rendered markdown lacks %s:\n%s", want, out) | ||
| 19 | } | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | // The stylesheet carries an ETag and a cache lifetime; a revalidation | ||
| 24 | // with the same tag is a 304 with no body (#132). | ||
| 25 | func TestStylesheetRevalidates(t *testing.T) { | ||
| 26 | s := New(config.Default(), nil) | ||
| 27 | first := httptest.NewRecorder() | ||
| 28 | s.stylesheet(first, httptest.NewRequest("GET", "/static/style.css", nil)) | ||
| 29 | tag := first.Header().Get("ETag") | ||
| 30 | if first.Code != 200 || tag == "" || first.Body.Len() == 0 || !strings.Contains(first.Header().Get("Cache-Control"), "max-age") { | ||
| 31 | t.Fatalf("first fetch: %d etag=%q cc=%q bytes=%d", first.Code, tag, first.Header().Get("Cache-Control"), first.Body.Len()) | ||
| 32 | } | ||
| 33 | req := httptest.NewRequest("GET", "/static/style.css", nil) | ||
| 34 | req.Header.Set("If-None-Match", tag) | ||
| 35 | second := httptest.NewRecorder() | ||
| 36 | s.stylesheet(second, req) | ||
| 37 | if second.Code != http.StatusNotModified || second.Body.Len() != 0 { | ||
| 38 | t.Fatalf("revalidation: %d with %d bytes", second.Code, second.Body.Len()) | ||
| 39 | } | ||
| 40 | } | ||
internal/httpd/web.go +25 −2
| @@ -2,6 +2,8 @@ package httpd | |||
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | "bytes" | 4 | "bytes" |
| 5 | "crypto/sha256" | ||
| 6 | "encoding/hex" | ||
| 5 | "errors" | 7 | "errors" |
| 6 | "fmt" | 8 | "fmt" |
| 7 | "hash/fnv" | 9 | "hash/fnv" |
| @@ -30,6 +32,7 @@ import ( | |||
| 30 | "github.com/yuin/goldmark" | 32 | "github.com/yuin/goldmark" |
| 31 | highlighting "github.com/yuin/goldmark-highlighting/v2" | 33 | highlighting "github.com/yuin/goldmark-highlighting/v2" |
| 32 | "github.com/yuin/goldmark/extension" | 34 | "github.com/yuin/goldmark/extension" |
| 35 | "github.com/yuin/goldmark/parser" | ||
| 33 | 36 | ||
| 34 | "gitbay.org/gitbay/internal/autolink" | 37 | "gitbay.org/gitbay/internal/autolink" |
| 35 | "gitbay.org/gitbay/internal/control" | 38 | "gitbay.org/gitbay/internal/control" |
| @@ -61,7 +64,23 @@ func (s *Server) siteName() string { | |||
| 61 | return strings.TrimSuffix(h, "/") | 64 | return strings.TrimSuffix(h, "/") |
| 62 | } | 65 | } |
| 63 | 66 | ||
| 67 | // stylesheetETag is the hash of what stylesheet serves, computed once: | ||
| 68 | // a browser revalidates with If-None-Match and gets a 304 until a deploy | ||
| 69 | // changes the bytes (#132). | ||
| 70 | var stylesheetETag = func() string { | ||
| 71 | h := sha256.New() | ||
| 72 | h.Write(web.StyleCSS) | ||
| 73 | h.Write(chromaCSS) | ||
| 74 | return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"` | ||
| 75 | }() | ||
| 76 | |||
| 64 | func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { | 77 | func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) { |
| 78 | w.Header().Set("ETag", stylesheetETag) | ||
| 79 | w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate") | ||
| 80 | if r.Header.Get("If-None-Match") == stylesheetETag { | ||
| 81 | w.WriteHeader(http.StatusNotModified) | ||
| 82 | return | ||
| 83 | } | ||
| 65 | w.Header().Set("Content-Type", "text/css; charset=utf-8") | 84 | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| 66 | w.Write(web.StyleCSS) | 85 | w.Write(web.StyleCSS) |
| 67 | w.Write(chromaCSS) | 86 | w.Write(chromaCSS) |
| @@ -1011,8 +1030,12 @@ func pickReadme(entries []gitutil.TreeEntry) string { | |||
| 1011 | // task lists) on top of CommonMark, with class-based fence highlighting | 1030 | // task lists) on top of CommonMark, with class-based fence highlighting |
| 1012 | // (the palette lives in the stylesheet, per scheme). Raw HTML is still | 1031 | // (the palette lives in the stylesheet, per scheme). Raw HTML is still |
| 1013 | // dropped. | 1032 | // dropped. |
| 1014 | var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, | 1033 | // Headings carry ids so a README or wiki section can be linked to, the |
| 1015 | highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true))))) | 1034 | // way org headings already are (#132). |
| 1035 | var markdown = goldmark.New( | ||
| 1036 | goldmark.WithParserOptions(parser.WithAutoHeadingID()), | ||
| 1037 | goldmark.WithExtensions(extension.GFM, | ||
| 1038 | highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true))))) | ||
| 1016 | 1039 | ||
| 1017 | // fenceHighlight renders one code block with chroma classes, for org and | 1040 | // fenceHighlight renders one code block with chroma classes, for org and |
| 1018 | // anything else outside goldmark. Unknown languages fall back to plain. | 1041 | // anything else outside goldmark. Unknown languages fall back to plain. |
internal/web/static/style.css +3
| @@ -763,6 +763,9 @@ button.linklike:hover { text-decoration: underline; filter: none; } | |||
| 763 | 763 | ||
| 764 | /* rendered markdown / org / html */ | 764 | /* rendered markdown / org / html */ |
| 765 | .rendered { max-width: 100%; overflow-wrap: anywhere; } | 765 | .rendered { max-width: 100%; overflow-wrap: anywhere; } |
| 766 | /* GFM task lists: the box sits in the line, sized to the prose. */ | ||
| 767 | .rendered li input[type="checkbox"] { width: 0.9em; height: 0.9em; margin: 0 0.35em 0 0; vertical-align: -0.05em; accent-color: var(--accent); } | ||
| 768 | .rendered li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.2em; } | ||
| 766 | /* line length is a reading constraint, so it applies to prose only — | 769 | /* line length is a reading constraint, so it applies to prose only — |
| 767 | tables, code blocks and images keep the full column */ | 770 | tables, code blocks and images keep the full column */ |
| 768 | .rendered > p, | 771 | .rendered > p, |