1package main
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "net/http"
8 "strings"
9
10 "github.com/alecthomas/chroma/v2"
11 "github.com/alecthomas/chroma/v2/formatters/html"
12 "github.com/alecthomas/chroma/v2/lexers"
13 "github.com/alecthomas/chroma/v2/styles"
14 "github.com/go-git/go-git/v5"
15 "github.com/go-git/go-git/v5/plumbing"
16 "github.com/go-git/go-git/v5/plumbing/filemode"
17 "github.com/go-git/go-git/v5/plumbing/object"
18 "github.com/yuin/goldmark"
19 "github.com/yuin/goldmark/extension"
20 "github.com/yuin/goldmark/parser"
21)
22
23func highlight(filename, content string) template.HTML {
24 lexer := lexers.Get(filename)
25 if lexer == nil {
26 lexer = lexers.Analyse(content)
27 }
28 if lexer == nil {
29 lexer = lexers.Fallback
30 }
31 lexer = chroma.Coalesce(lexer)
32
33 style := styles.Get("vs")
34 if style == nil {
35 style = styles.Fallback
36 }
37
38 formatter := html.New(html.WithLineNumbers(true), html.WithLinkableLineNumbers(true, "L"))
39
40 iterator, err := lexer.Tokenise(nil, content)
41 if err != nil {
42 return template.HTML(template.HTMLEscapeString(content))
43 }
44
45 var sb strings.Builder
46 err = formatter.Format(&sb, style, iterator)
47 if err != nil {
48 return template.HTML(template.HTMLEscapeString(content))
49 }
50
51 return template.HTML(sb.String())
52}
53
54func scanMarkers(ctx *RepoContext) ([]Marker, error) {
55 commit, err := ctx.GitRepo.CommitObject(ctx.Hash)
56 if err != nil {
57 return nil, err
58 }
59
60 tree, err := commit.Tree()
61 if err != nil {
62 return nil, err
63 }
64
65 var markers []Marker
66 err = tree.Files().ForEach(func(f *object.File) error {
67 // Skip vendor directory and binary files
68 if strings.HasPrefix(f.Name, "vendor/") || strings.Contains(f.Name, "/vendor/") {
69 return nil
70 }
71 if f.Mode.IsFile() && f.Size > 1024*1024 { // Skip files > 1MB
72 return nil
73 }
74
75 isSource := false
76 exts := []string{".go", ".js", ".ts", ".py", ".c", ".h", ".cpp", ".hpp", ".css", ".html", ".md", ".txt", ".yaml", ".yml", ".sh"}
77 for _, ext := range exts {
78 if strings.HasSuffix(strings.ToLower(f.Name), ext) {
79 isSource = true
80 break
81 }
82 }
83
84 if !isSource {
85 return nil
86 }
87
88 content, err := f.Contents()
89 if err != nil {
90 return nil
91 }
92
93 lines := strings.Split(content, "\n")
94 for i, line := range lines {
95 trimmed := strings.TrimSpace(line)
96 markerType := ""
97 if strings.Contains(trimmed, "TODO:") {
98 markerType = "TODO"
99 } else if strings.Contains(trimmed, "FIXME:") {
100 markerType = "FIXME"
101 }
102
103 if markerType != "" {
104 // Extract content after the marker
105 idx := strings.Index(trimmed, markerType+":")
106 content := strings.TrimSpace(trimmed[idx+len(markerType)+1:])
107 markers = append(markers, Marker{
108 Type: markerType,
109 FilePath: f.Name,
110 Line: i + 1,
111 Content: content,
112 })
113 }
114 }
115 return nil
116 })
117
118 return markers, err
119}
120
121func getRepoContext(w http.ResponseWriter, r *http.Request) (*RepoContext, error) {
122 name := r.PathValue("name")
123 config := GlobalConfig
124
125 var repo *Repository
126 for _, repoItem := range config.Repositories {
127 if repoItem.Name == name {
128 repo = &repoItem
129 break
130 }
131 }
132
133 if repo == nil {
134 return nil, fmt.Errorf("repository not found")
135 }
136
137 gitRepo, err := git.PlainOpen(repo.Path)
138 if err != nil {
139 return nil, fmt.Errorf("error opening repository: %w", err)
140 }
141
142 refName := r.URL.Query().Get("ref")
143 var hash plumbing.Hash
144
145 if refName == "" {
146 head, err := gitRepo.Head()
147 if err != nil {
148 return nil, fmt.Errorf("error getting HEAD: %w", err)
149 }
150 hash = head.Hash()
151 refName = head.Name().Short()
152 } else {
153 h, err := gitRepo.ResolveRevision(plumbing.Revision(refName))
154 if err != nil {
155 return nil, fmt.Errorf("error resolving revision: %w", err)
156 }
157 hash = *h
158 }
159
160 metadataKey := name + ":" + hash.String()
161
162 if val, ok := repoMetadataCache.Load(metadataKey); ok {
163 meta := val.(RepoMetadata)
164 if meta.Version >= CurrentMetadataVersion {
165 return &RepoContext{
166 Repo: repo,
167 GitRepo: gitRepo,
168 Config: config,
169 CurrentRef: refName,
170 Hash: hash,
171 Branches: meta.Branches,
172 Tags: meta.Tags,
173 ReadmeName: meta.ReadmeName,
174 LicenseName: meta.LicenseName,
175 }, nil
176 }
177 }
178
179 // Get all branch-like references (local and remote)
180 var branches []string
181 rIter, err := gitRepo.References()
182 seenBranches := make(map[string]bool)
183 if err == nil {
184 rIter.ForEach(func(r *plumbing.Reference) error {
185 if r.Name().IsBranch() || r.Name().IsRemote() {
186 name := r.Name().Short()
187 if name == "origin/HEAD" || seenBranches[name] {
188 return nil
189 }
190 branches = append(branches, name)
191 seenBranches[name] = true
192 }
193 return nil
194 })
195 }
196
197 // Get tags
198 var tags []string
199 tIter, err := gitRepo.Tags()
200 if err == nil {
201 tIter.ForEach(func(r *plumbing.Reference) error {
202 tags = append(tags, r.Name().Short())
203 return nil
204 })
205 }
206
207 // Detect README and LICENSE
208 readmeName := ""
209 licenseName := ""
210 commit, err := gitRepo.CommitObject(hash)
211 if err == nil {
212 tree, err := commit.Tree()
213 if err == nil {
214 for _, entry := range tree.Entries {
215 nameLower := strings.ToLower(entry.Name)
216 if readmeName == "" && (nameLower == "readme.md" || nameLower == "readme.markdown" || nameLower == "readme") {
217 readmeName = entry.Name
218 }
219 if licenseName == "" && (nameLower == "license" || nameLower == "license.md" || nameLower == "license.txt" || nameLower == "licence" || nameLower == "licence.md" || nameLower == "licence.txt" || nameLower == "copying" || nameLower == "copying.md" || nameLower == "copying.txt") {
220 licenseName = entry.Name
221 }
222 if readmeName != "" && licenseName != "" {
223 break
224 }
225 }
226 }
227 }
228
229 // Note: totalCommits will be updated in repoHandler if needed,
230 // for now we store what we have.
231 repoMetadataCache.Store(metadataKey, RepoMetadata{
232 Branches: branches,
233 Tags: tags,
234 ReadmeName: readmeName,
235 LicenseName: licenseName,
236 Version: CurrentMetadataVersion,
237 })
238 NotifySave()
239
240 return &RepoContext{
241 Repo: repo,
242 GitRepo: gitRepo,
243 Config: config,
244 CurrentRef: refName,
245 Hash: hash,
246 Branches: branches,
247 Tags: tags,
248 ReadmeName: readmeName,
249 LicenseName: licenseName,
250 }, nil
251}
252
253func formatMode(m filemode.FileMode) string {
254 if m == filemode.Empty {
255 return "----------"
256 }
257 s := m.String()
258 switch m {
259 case filemode.Regular:
260 return "-rw-r--r--"
261 case filemode.Executable:
262 return "-rwxr-xr-x"
263 case filemode.Dir:
264 return "drwxr-xr-x"
265 case filemode.Symlink:
266 return "lrwxrwxrwx"
267 default:
268 return s
269 }
270}
271
272func renderMarkdown(content string) template.HTML {
273 md := goldmark.New(
274 goldmark.WithExtensions(
275 extension.GFM,
276 extension.Linkify,
277 extension.Table,
278 AlertExtension,
279 ),
280 goldmark.WithParserOptions(
281 parser.WithAutoHeadingID(),
282 ),
283 )
284 var buf bytes.Buffer
285 if err := md.Convert([]byte(content), &buf); err != nil {
286 return template.HTML(content)
287 }
288 return template.HTML(buf.String())
289}