8 files changed,
206 insertions(+),
173 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-03-01 23:47:04 +0200
Authored at:
2026-02-28 19:53:55 +0200
Change ID:
xnmzwkwturptswnkzyztruzsmutoxyxr
Parent:
365b06c
M
flake.nix
路路路 15 15 pname = "mugit"; 16 16 version = version; 17 17 src = ./.; 18 - vendorHash = "sha256-xF8IRS0Ne1zp4u6uolKFpKEZObSM6VhV95JUj2krXPY="; 18 + vendorHash = "sha256-TmCwI6axUTGZEVTXyBxQbO4mbK8Dn9vaTU+/Y+K1oxM="; 19 19 ldflags = [ "-s" "-w" "-X main.version=${version}" ]; 20 20 meta = with pkgs.lib; { 21 21 homepage = "https://git.olexsmir.xyz/mugit";
M
go.mod
路路路 11 11 github.com/urfave/cli/v3 v3.6.2 12 12 github.com/yuin/goldmark v1.7.16 13 13 github.com/yuin/goldmark-emoji v1.0.6 14 + gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab 14 15 golang.org/x/crypto v0.47.0 15 16 golang.org/x/sync v0.19.0 16 17 gopkg.in/yaml.v2 v2.4.0
M
go.sum
路路路 77 77 github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 78 78 github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= 79 79 github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= 80 +gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A= 81 +gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab/go.mod h1:SPu13/NPe1kMrbGoJldQwqtpNhXsmIuHCfm/aaGjU0c= 80 82 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 81 83 golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= 82 84 golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
M
internal/handlers/repo.go
路路路 15 15 "strings" 16 16 "time" 17 17 18 - "github.com/yuin/goldmark" 19 - emoji "github.com/yuin/goldmark-emoji" 20 - "github.com/yuin/goldmark/extension" 21 - "github.com/yuin/goldmark/renderer/html" 22 18 "olexsmir.xyz/mugit/internal/git" 23 - "olexsmir.xyz/mugit/internal/mdx" 19 + "olexsmir.xyz/mugit/internal/markdown" 24 20 ) 25 21 26 22 type Meta struct { 路路路 459 455 return diff, nil 460 456 } 461 457 462 -var markdown = goldmark.New( 463 - goldmark.WithRendererOptions(html.WithUnsafe()), 464 - goldmark.WithExtensions( 465 - extension.GFM, 466 - extension.Linkify, 467 - emoji.Emoji, 468 - mdx.RelativeLink, 469 - )) 470 - 471 458 func (h *handlers) renderReadme(r *git.Repo, ref, treePath string) (template.HTML, error) { 472 459 name := r.Name() 473 460 cacheKey := fmt.Sprintf("%s:%s:%s", name, ref, treePath) 路路路 492 479 if len(content) > 0 { 493 480 switch ext { 494 481 case ".md", ".markdown", ".mkd": 495 - var buf bytes.Buffer 496 - if cerr := markdown.Convert([]byte(content), &buf, 497 - mdx.NewRelativeLinkCtx(name, fullPath)); cerr != nil { 498 - return "", cerr 482 + readme, err := markdown.Render(name, ref, fullPath, content) 483 + if err != nil { 484 + return "", err 499 485 } 500 - readmeContents = template.HTML(buf.String()) 486 + return template.HTML(readme), nil 487 + 501 488 default: 502 489 readmeContents = template.HTML(fmt.Sprintf(`<pre>%s</pre>`, content)) 503 490 }
A
internal/markdown/markdown.go
路路路 1 +package markdown 2 + 3 +import ( 4 + "bytes" 5 + "path/filepath" 6 + 7 + "github.com/yuin/goldmark" 8 + emoji "github.com/yuin/goldmark-emoji" 9 + "github.com/yuin/goldmark/extension" 10 + "github.com/yuin/goldmark/parser" 11 + "github.com/yuin/goldmark/renderer/html" 12 + callout "gitlab.com/staticnoise/goldmark-callout" 13 +) 14 + 15 +var ( 16 + repoNameKey = parser.NewContextKey() 17 + repoRefKey = parser.NewContextKey() 18 + baseDirKey = parser.NewContextKey() 19 + 20 + markdown = goldmark.New( 21 + goldmark.WithRendererOptions(html.WithUnsafe()), 22 + goldmark.WithParserOptions(parser.WithAutoHeadingID()), 23 + goldmark.WithExtensions( 24 + extension.GFM, 25 + emoji.Emoji, 26 + callout.CalloutExtention, 27 + &relativeLink{}, 28 + )) 29 +) 30 + 31 +func Render(repoName, repoRef, readmePath, readmeSource string) (string, error) { 32 + ctx := parser.NewContext() 33 + ctx.Set(repoNameKey, repoName) 34 + ctx.Set(repoRefKey, repoRef) 35 + ctx.Set(baseDirKey, filepath.Dir(readmePath)) 36 + parserOpts := parser.WithContext(ctx) 37 + 38 + var buf bytes.Buffer 39 + if err := markdown.Convert([]byte(readmeSource), &buf, parserOpts); err != nil { 40 + return "", err 41 + } 42 + return buf.String(), nil 43 +}
A
internal/markdown/relink.go
路路路 1 +package markdown 2 + 3 +import ( 4 + "bytes" 5 + "net/url" 6 + "path" 7 + "regexp" 8 + "strings" 9 + 10 + "github.com/yuin/goldmark" 11 + "github.com/yuin/goldmark/ast" 12 + "github.com/yuin/goldmark/parser" 13 + "github.com/yuin/goldmark/renderer" 14 + "github.com/yuin/goldmark/text" 15 + "github.com/yuin/goldmark/util" 16 +) 17 + 18 +type relativeLink struct{} 19 + 20 +func (e *relativeLink) Extend(m goldmark.Markdown) { 21 + m.Parser().AddOptions(parser.WithASTTransformers(util.Prioritized(&relLinkTransformer{}, 99))) 22 + m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(&rawBlockRenderer{}, 100))) 23 +} 24 + 25 +type relLinkTransformer struct { 26 + repoName string 27 + repoRef string 28 + baseDir string 29 +} 30 + 31 +func (m *relLinkTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 32 + m.repoName, _ = pc.Get(repoNameKey).(string) 33 + m.repoRef, _ = pc.Get(repoRefKey).(string) 34 + m.baseDir, _ = pc.Get(baseDirKey).(string) 35 + 36 + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 37 + if !entering { 38 + return ast.WalkContinue, nil 39 + } 40 + 41 + switch n := n.(type) { 42 + case *ast.Link: 43 + m.relativeLinkTransformer(n) 44 + 45 + case *ast.Image: 46 + m.imageFromRepoTransformer(n) 47 + 48 + case *ast.RawHTML: 49 + buf := m.segmentsToBytes(n.Segments, reader) 50 + updated := m.rewriteSrc(buf) 51 + if !bytes.Equal(updated, buf) { 52 + s := ast.NewString(updated) 53 + s.SetRaw(true) 54 + n.Parent().ReplaceChild(n.Parent(), n, s) 55 + } 56 + 57 + case *ast.HTMLBlock: 58 + buf := m.segmentsToBytes(n.Lines(), reader) 59 + updated := m.rewriteSrc(buf) 60 + if !bytes.Equal(updated, buf) { 61 + n.Parent().ReplaceChild(n.Parent(), n, &rawBlock{data: updated}) 62 + } 63 + } 64 + 65 + return ast.WalkContinue, nil 66 + }) 67 +} 68 + 69 +func (m *relLinkTransformer) relativeLinkTransformer(link *ast.Link) { 70 + dst := string(link.Destination) 71 + if isAbsoluteURL(dst) { 72 + return 73 + } 74 + 75 + act := m.path(dst) 76 + link.Destination = []byte(path.Join("/", m.repoName, "tree", m.repoRef, act)) 77 +} 78 + 79 +func (m *relLinkTransformer) imageFromRepoTransformer(img *ast.Image) { 80 + img.Destination = []byte(m.imageFromRepo( 81 + string(img.Destination))) 82 +} 83 + 84 +func (m *relLinkTransformer) imageFromRepo(dst string) string { 85 + if isAbsoluteURL(dst) { 86 + return dst 87 + } 88 + 89 + absPath := m.path(dst) 90 + return path.Join("/", url.PathEscape(m.repoName), "blob", m.repoRef, absPath) + 91 + "?raw=true" 92 +} 93 + 94 +func (m *relLinkTransformer) path(dst string) string { 95 + if path.IsAbs(dst) { 96 + return dst 97 + } 98 + return path.Join(m.baseDir, dst) 99 +} 100 + 101 +var imgSrcRe = regexp.MustCompile(`src="([^"]*)"`) 102 + 103 +func (m *relLinkTransformer) rewriteSrc(buf []byte) []byte { 104 + return imgSrcRe.ReplaceAllFunc(buf, func(match []byte) []byte { 105 + start := bytes.IndexByte(match, '"') + 1 106 + end := bytes.LastIndexByte(match, '"') 107 + src := string(match[start:end]) 108 + return []byte(`src="` + m.imageFromRepo(src) + `"`) 109 + }) 110 +} 111 + 112 +func (m *relLinkTransformer) segmentsToBytes(segs *text.Segments, reader text.Reader) []byte { 113 + var buf []byte 114 + for i := 0; i < segs.Len(); i++ { 115 + buf = append(buf, reader.Value(segs.At(i))...) 116 + } 117 + return buf 118 +} 119 + 120 +func isAbsoluteURL(link string) bool { 121 + if strings.HasPrefix(link, "#") { 122 + return true 123 + } 124 + u, err := url.Parse(link) 125 + return err == nil && (u.Scheme != "" || strings.HasPrefix(link, "//")) 126 +} 127 + 128 +// row block 129 + 130 +type rawBlock struct { 131 + ast.BaseBlock 132 + data []byte 133 +} 134 + 135 +var rawBlockKind = ast.NewNodeKind("RawBlock") 136 + 137 +func (r *rawBlock) Kind() ast.NodeKind { return rawBlockKind } 138 +func (r *rawBlock) Dump(_ []byte, _ int) {} 139 + 140 +type rawBlockRenderer struct{} 141 + 142 +func (r *rawBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 143 + reg.Register(rawBlockKind, func(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 144 + if entering { 145 + w.Write(node.(*rawBlock).data) 146 + } 147 + return ast.WalkContinue, nil 148 + }) 149 +}
D
internal/mdx/relative_link.go
路路路 1 -package mdx 2 - 3 -import ( 4 - "bytes" 5 - "fmt" 6 - "net/url" 7 - "path" 8 - "path/filepath" 9 - "regexp" 10 - "strings" 11 - 12 - "github.com/yuin/goldmark" 13 - "github.com/yuin/goldmark/ast" 14 - "github.com/yuin/goldmark/parser" 15 - "github.com/yuin/goldmark/renderer" 16 - "github.com/yuin/goldmark/text" 17 - "github.com/yuin/goldmark/util" 18 -) 19 - 20 -var ( 21 - repoNameKey = parser.NewContextKey() 22 - baseDirKey = parser.NewContextKey() 23 -) 24 - 25 -func NewRelativeLinkCtx(repoName, readmePath string) parser.ParseOption { 26 - ctx := parser.NewContext() 27 - ctx.Set(repoNameKey, repoName) 28 - ctx.Set(baseDirKey, filepath.Dir(readmePath)) 29 - return parser.WithContext(ctx) 30 -} 31 - 32 -var RelativeLink = &relativeLink{} 33 - 34 -type relativeLink struct{} 35 - 36 -func (e *relativeLink) Extend(m goldmark.Markdown) { 37 - m.Parser().AddOptions( 38 - parser.WithASTTransformers(util.Prioritized(&relinkTransformer{}, 100)), 39 - ) 40 - m.Renderer().AddOptions( 41 - renderer.WithNodeRenderers(util.Prioritized(&rawBlockRenderer{}, 100)), 42 - ) 43 -} 44 - 45 -type rawBlock struct { 46 - ast.BaseBlock 47 - data []byte 48 -} 49 - 50 -var rawBlockKind = ast.NewNodeKind("RawBlock") 51 - 52 -func (r *rawBlock) Kind() ast.NodeKind { return rawBlockKind } 53 -func (r *rawBlock) Dump(_ []byte, _ int) {} 54 - 55 -type rawBlockRenderer struct{} 56 - 57 -func (r *rawBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 58 - reg.Register(rawBlockKind, func(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { 59 - if entering { 60 - w.Write(node.(*rawBlock).data) 61 - } 62 - return ast.WalkContinue, nil 63 - }) 64 -} 65 - 66 -type relinkTransformer struct{} 67 - 68 -func (t *relinkTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 69 - repoName, _ := pc.Get(repoNameKey).(string) 70 - baseDir, _ := pc.Get(baseDirKey).(string) 71 - 72 - _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 73 - if !entering { 74 - return ast.WalkContinue, nil 75 - } 76 - 77 - switch v := n.(type) { 78 - case *ast.Image: 79 - if rewritten := t.rewriteDest(v.Destination, repoName, baseDir); rewritten != nil { 80 - v.Destination = rewritten 81 - } 82 - case *ast.Link: 83 - if rewritten := t.rewriteDest(v.Destination, repoName, baseDir); rewritten != nil { 84 - v.Destination = rewritten 85 - } 86 - case *ast.RawHTML: 87 - var buf []byte 88 - for i := 0; i < v.Segments.Len(); i++ { 89 - buf = append(buf, reader.Value(v.Segments.At(i))...) 90 - } 91 - updated := t.rewriteSrc(buf, repoName, baseDir) 92 - if !bytes.Equal(updated, buf) { 93 - str := ast.NewString(updated) 94 - str.SetRaw(true) 95 - n.Parent().ReplaceChild(n.Parent(), n, str) 96 - } 97 - case *ast.HTMLBlock: 98 - var buf []byte 99 - for i := 0; i < v.Lines().Len(); i++ { 100 - buf = append(buf, reader.Value(v.Lines().At(i))...) 101 - } 102 - updated := t.rewriteSrc(buf, repoName, baseDir) 103 - if !bytes.Equal(updated, buf) { 104 - n.Parent().ReplaceChild(n.Parent(), n, &rawBlock{data: updated}) 105 - } 106 - } 107 - 108 - return ast.WalkContinue, nil 109 - }) 110 -} 111 - 112 -func (t *relinkTransformer) rewriteDest(dest []byte, repoName, baseDir string) []byte { 113 - urlStr := string(dest) 114 - if strings.HasPrefix(urlStr, "http://") || 115 - strings.HasPrefix(urlStr, "https://") || 116 - strings.HasPrefix(urlStr, "//") || 117 - strings.HasPrefix(urlStr, "#") || 118 - strings.HasPrefix(urlStr, "mailto:") || 119 - strings.HasPrefix(urlStr, "data:") { 120 - return nil 121 - } 122 - urlStr = strings.TrimPrefix(urlStr, "./") 123 - 124 - var absPath string 125 - if after, ok := strings.CutPrefix(urlStr, "/"); ok { 126 - absPath = after 127 - } else { 128 - if baseDir == "" || baseDir == "." { 129 - absPath = urlStr 130 - } else { 131 - absPath = path.Join(baseDir, urlStr) 132 - } 133 - } 134 - 135 - absPath = strings.TrimPrefix(path.Clean(absPath), "/") 136 - 137 - // FIXME: hardcoded ref and link 138 - return fmt.Appendf(nil, "/%s/blob/HEAD/%s?raw=true", url.PathEscape(repoName), absPath) 139 -} 140 - 141 -var imgSrcRe = regexp.MustCompile(`src="([^"]*)"`) 142 - 143 -func (t *relinkTransformer) rewriteSrc(buf []byte, repoName, baseDir string) []byte { 144 - return imgSrcRe.ReplaceAllFunc(buf, func(match []byte) []byte { 145 - src := imgSrcRe.FindSubmatch(match)[1] 146 - rewritten := t.rewriteDest(src, repoName, baseDir) 147 - if rewritten == nil { 148 - return match 149 - } 150 - return append([]byte(`src="`), append(rewritten, '"')...) 151 - }) 152 -}
M
web/static/style.css
路路路 370 370 371 371 /* readme */ 372 372 .readme { padding: 0.6rem 0; } 373 -.readme pre { white-space: pre-wrap; } 374 373 .readme ul { padding: revert; } 375 374 .readme img { max-width: 100%; } 375 +.readme pre { 376 + background: var(--light-gray); 377 + padding: 0.6rem; 378 +} 376 379 377 380 @media (max-width: 600px) { 378 381 .repo-index {