Commit 4e9469b

Eric Bower  ·  2026-09-14 13:26:05 -0400 EDT
parent a953040
feat: markdown to html
7 files changed,  +191, -15
M go.mod
M go.sum
+1, -1
......@@ -7,7 +7,7 @@ RUN go mod download && go mod verify
77
88 COPY . /app
99
10-RUN go build -v -o pgit main.go
10+RUN go build -v -o pgit .
1111
1212 FROM debian:12
1313 WORKDIR /app
+5, -2
......@@ -10,7 +10,7 @@ clean:
1010 .PHONY: clean
1111
1212 build:
13- go build -o pgit ./main.go
13+ go build -o pgit .
1414 .PHONY: build
1515
1616 img:
......@@ -29,8 +29,11 @@ test:
2929 go test ./...
3030 .PHONY: test
3131
32+check: lint test
33+.PHONY: check
34+
3235 static:
33- go run main.go \
36+ go run . \
3437 --out ./public \
3538 --label pgit \
3639 --clone-url "https://github.com/picosh/pgit.git" \
M go.mod
+8, -1
......@@ -6,11 +6,18 @@ require (
66 github.com/alecthomas/chroma/v2 v2.13.0
77 github.com/dustin/go-humanize v1.0.0
88 github.com/gogs/git-module v1.6.0
9+ github.com/yuin/goldmark v1.8.2
10+ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
11+ github.com/yuin/goldmark-meta v1.1.0
12+ go.abhg.dev/goldmark/anchor v0.2.0
13+ go.abhg.dev/goldmark/hashtag v0.4.0
914 )
1015
1116 require (
1217 github.com/dlclark/regexp2 v1.11.0 // indirect
18+ github.com/forPelevin/gomoji v1.3.0 // indirect
1319 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 // indirect
14- github.com/stretchr/testify v1.8.1 // indirect
20+ github.com/rivo/uniseg v0.4.7 // indirect
1521 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
22+ gopkg.in/yaml.v2 v2.3.0 // indirect
1623 )
M go.sum
+25, -6
......@@ -1,16 +1,22 @@
11 github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
22 github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
3+github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
34 github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
45 github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
6+github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
57 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
68 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
79 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
810 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
911 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
13+github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
1014 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
1115 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
1216 github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
1317 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
18+github.com/forPelevin/gomoji v1.3.0 h1:WPIOLWB1bvRYlKZnSSEevLt3IfKlLs+tK+YA9fFYlkE=
19+github.com/forPelevin/gomoji v1.3.0/go.mod h1:mM6GtmCgpoQP2usDArc6GjbXrti5+FffolyQfGgPboQ=
1420 github.com/gogs/git-module v1.6.0 h1:71GdRM9/pFxGgSUz8t2DKmm3RYuHUnTjsOuFInJXnkM=
1521 github.com/gogs/git-module v1.6.0/go.mod h1:8jFYhDxLUwEOhM2709l2CJXmoIIslobU1xszpT0NcAI=
1622 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
......@@ -19,19 +25,32 @@ github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX
1925 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
2026 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
2127 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
28+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
29+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
2230 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
23-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
24-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
2531 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
26-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
27-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
28-github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
29-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
32+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
33+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
34+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
35+github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
36+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
37+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
38+github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
39+github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
40+github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
41+github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
42+go.abhg.dev/goldmark/anchor v0.2.0 h1:RQZTodRc6VHSUoQYKFlyH0pokbhk1klwUuGgDmjGp2E=
43+go.abhg.dev/goldmark/anchor v0.2.0/go.mod h1:Ym74zBV+QBKxK9ITOty680N9FT8otgGYvtYXroJUWms=
44+go.abhg.dev/goldmark/hashtag v0.4.0 h1:jkYcsIRUpog/rhWMnIOjj14qj8p5p4um+IzPbPUxFww=
45+go.abhg.dev/goldmark/hashtag v0.4.0/go.mod h1:5DQwSo2dEHKtsfXOC1+Po7WgVwR5IoGjgeVIerddz9g=
3046 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
3147 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw=
3248 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
49+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
3350 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3451 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
52+gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
53+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
3554 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
3655 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
3756 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+18, -5
......@@ -403,7 +403,7 @@ func (c *Config) writeHtml(writeData *WriteData) {
403403 bail(err)
404404 }
405405
406-// writeHtmlIfChanged renders a template and only writes if content hash differs from existing file
406+// writeHtmlIfChanged renders a template and only writes if content hash differs from existing file.
407407 func (c *Config) writeHtmlIfChanged(writeData *WriteData) {
408408 ts, err := template.New("").Funcs(funcMap).ParseFS(
409409 embedFS,
......@@ -431,7 +431,9 @@ func (c *Config) writeHtmlIfChanged(writeData *WriteData) {
431431
432432 // compare with existing file
433433 if f, err := os.Open(fp); err == nil {
434- defer f.Close()
434+ defer func() {
435+ _ = f.Close()
436+ }()
435437 h := sha256.New()
436438 if _, err := io.Copy(h, f); err == nil {
437439 if bytes.Equal(h.Sum(nil), newHash[:]) {
......@@ -522,6 +524,11 @@ func (c *Config) writeRefs(data PageData, refs []*RefInfo) {
522524 })
523525 }
524526
527+func isMarkdown(filename string) bool {
528+ ext := strings.ToLower(filepath.Ext(filename))
529+ return ext == ".md" || ext == ".markdown"
530+}
531+
525532 func (c *Config) writeHTMLTreeFile(pageData PageData, treeItem *TreeItem) string {
526533 pageData.ActivePage = "code"
527534 d := filepath.Dir(treeItem.Path)
......@@ -535,8 +542,14 @@ func (c *Config) writeHTMLTreeFile(pageData PageData, treeItem *TreeItem) string
535542 contents := "binary file, cannot display"
536543 if treeItem.IsTextFile {
537544 treeItem.NumLines = len(strings.Split(str, "\n"))
538- contents, err = c.parseText(treeItem.Entry.Name(), string(b))
539- bail(err)
545+ if isMarkdown(treeItem.Entry.Name()) {
546+ html, err := ParseMarkdown(str)
547+ bail(err)
548+ contents = html
549+ } else {
550+ contents, err = c.parseText(treeItem.Entry.Name(), str)
551+ bail(err)
552+ }
540553 }
541554
542555 nameLower := strings.ToLower(treeItem.Entry.Name())
......@@ -718,7 +731,7 @@ func getShortID(id string) string {
718731 return id[:7]
719732 }
720733
721-// countCommits returns the total number of commits reachable from the given ref
734+// countCommits returns the total number of commits reachable from the given ref.
722735 func countCommits(repo *git.Repository, ref string) int {
723736 // git rev-list --count is the most efficient way to get total commit count
724737 cmd := exec.Command("git", "rev-list", "--count", ref)
+59, -0
......@@ -0,0 +1,59 @@
1+package main
2+
3+import (
4+ "bytes"
5+
6+ "github.com/alecthomas/chroma/v2/formatters/html"
7+ "github.com/yuin/goldmark"
8+ highlighting "github.com/yuin/goldmark-highlighting/v2"
9+ meta "github.com/yuin/goldmark-meta"
10+ "github.com/yuin/goldmark/extension"
11+ "github.com/yuin/goldmark/parser"
12+ ghtml "github.com/yuin/goldmark/renderer/html"
13+ gtext "github.com/yuin/goldmark/text"
14+ "go.abhg.dev/goldmark/anchor"
15+ "go.abhg.dev/goldmark/hashtag"
16+)
17+
18+func CreateGoldmark(extenders ...goldmark.Extender) goldmark.Markdown {
19+ return goldmark.New(
20+ goldmark.WithExtensions(
21+ extenders...,
22+ ),
23+ goldmark.WithParserOptions(
24+ parser.WithAutoHeadingID(),
25+ ),
26+ goldmark.WithRendererOptions(
27+ ghtml.WithUnsafe(),
28+ ),
29+ )
30+}
31+
32+func ParseMarkdown(text string) (string, error) {
33+ hili := highlighting.NewHighlighting(
34+ highlighting.WithFormatOptions(
35+ html.WithClasses(true),
36+ ),
37+ )
38+ extenders := []goldmark.Extender{
39+ extension.GFM,
40+ extension.Footnote,
41+ meta.Meta,
42+ &hashtag.Extender{},
43+ hili,
44+ &anchor.Extender{
45+ Position: anchor.After,
46+ Texter: anchor.Text("#"),
47+ },
48+ }
49+ md := CreateGoldmark(extenders...)
50+ context := parser.NewContext()
51+ btext := []byte(text)
52+ doc := md.Parser().Parse(gtext.NewReader(btext), parser.WithContext(context))
53+
54+ var buf bytes.Buffer
55+ if err := md.Renderer().Render(&buf, btext, doc); err != nil {
56+ return "", err
57+ }
58+ return buf.String(), nil
59+}
+75, -0
......@@ -0,0 +1,75 @@
1+package main
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestIsMarkdown(t *testing.T) {
9+ tests := []struct {
10+ filename string
11+ expected bool
12+ }{
13+ {"README.md", true},
14+ {"readme.markdown", true},
15+ {"doc.MD", true},
16+ {"main.go", false},
17+ {"Makefile", false},
18+ {"file.txt", false},
19+ }
20+
21+ for _, tt := range tests {
22+ t.Run(tt.filename, func(t *testing.T) {
23+ if got := isMarkdown(tt.filename); got != tt.expected {
24+ t.Errorf("isMarkdown(%q) = %v, expected %v", tt.filename, got, tt.expected)
25+ }
26+ })
27+ }
28+}
29+
30+func TestParseMarkdown(t *testing.T) {
31+ input := `# Hello World
32+
33+This is a paragraph with **bold** and *italic* text.
34+
35+## Features
36+
37+- Item 1
38+- Item 2
39+
40+### Code block
41+
42+` + "```go\nfunc main() {\n\tprintln(\"hello\")\n}\n```" + `
43+
44+### Table
45+
46+| Name | Role |
47+|------|------|
48+| Alice | Admin |
49+`
50+
51+ html, err := ParseMarkdown(input)
52+ if err != nil {
53+ t.Fatalf("ParseMarkdown returned error: %v", err)
54+ }
55+
56+ // Heading should not be deleted from HTML
57+ if !strings.Contains(html, `<h1 id="hello-world">Hello World`) {
58+ t.Errorf("expected HTML to contain rendered <h1>, got:\n%s", html)
59+ }
60+
61+ // Heading should have anchor
62+ if !strings.Contains(html, `<a class="anchor" href="#hello-world"`) {
63+ t.Errorf("expected anchor on heading, got:\n%s", html)
64+ }
65+
66+ // Code block should have chroma classes
67+ if !strings.Contains(html, `class="chroma"`) {
68+ t.Errorf("expected code block to have chroma class, got:\n%s", html)
69+ }
70+
71+ // Table should be rendered
72+ if !strings.Contains(html, `<table>`) {
73+ t.Errorf("expected table to be rendered, got:\n%s", html)
74+ }
75+}