scripts/
static/
templates/
.gitignore
30 B
.gitsigners
112 B
AGENTS.md
7.5 KiB
LICENSE
89 B
README.md
1.9 KiB
deploy
723 B
discuss.go
16.7 KiB
git.go
3.5 KiB
git_cli.go
16.0 KiB
git_http.go
1.9 KiB
go.mod
572 B
go.sum
1.9 KiB
handler.go
11.3 KiB
handler_test.go
69.0 KiB
main.go
5.2 KiB
template.go
8.9 KiB
watch
272 B
template.go
raw
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "embed" |
| 5 | "fmt" |
| 6 | "html" |
| 7 | "html/template" |
| 8 | "io" |
| 9 | "path" |
| 10 | "regexp" |
| 11 | "strings" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | //go:embed templates/*.html |
| 16 | var templateFS embed.FS |
| 17 | |
| 18 | //go:embed static/style.css |
| 19 | var cssContent []byte |
| 20 | |
| 21 | //go:embed static/radiant.svg |
| 22 | var logoContent []byte |
| 23 | |
| 24 | //go:embed static/fonts/* |
| 25 | var fontsFS embed.FS |
| 26 | |
| 27 | //go:embed static/avatars/* |
| 28 | var avatarsFS embed.FS |
| 29 | |
| 30 | //go:embed static/js/hirad.js |
| 31 | var hiradJS []byte |
| 32 | |
| 33 | //go:embed static/js/hiril.js |
| 34 | var hirilJS []byte |
| 35 | |
| 36 | type templateSet struct { |
| 37 | templates map[string]*template.Template |
| 38 | } |
| 39 | |
| 40 | var funcMap = template.FuncMap{ |
| 41 | "shortHash": func(h string) string { |
| 42 | if len(h) > 8 { |
| 43 | return h[:8] |
| 44 | } |
| 45 | return h |
| 46 | }, |
| 47 | "timeAgo": func(t time.Time) template.HTML { |
| 48 | d := time.Since(t) |
| 49 | var ago string |
| 50 | switch { |
| 51 | case d < time.Minute: |
| 52 | ago = "just now" |
| 53 | case d < time.Hour: |
| 54 | m := int(d.Minutes()) |
| 55 | if m == 1 { |
| 56 | ago = "1 minute" |
| 57 | } else { |
| 58 | ago = fmt.Sprintf("%d minutes", m) |
| 59 | } |
| 60 | case d < 24*time.Hour: |
| 61 | h := int(d.Hours()) |
| 62 | if h == 1 { |
| 63 | ago = "1 hour" |
| 64 | } else { |
| 65 | ago = fmt.Sprintf("%d hours", h) |
| 66 | } |
| 67 | case d < 30*24*time.Hour: |
| 68 | days := int(d.Hours() / 24) |
| 69 | if days == 1 { |
| 70 | ago = "1 day" |
| 71 | } else { |
| 72 | ago = fmt.Sprintf("%d days", days) |
| 73 | } |
| 74 | case d < 365*24*time.Hour: |
| 75 | months := int(d.Hours() / 24 / 30) |
| 76 | if months == 1 { |
| 77 | ago = "1 month" |
| 78 | } else { |
| 79 | ago = fmt.Sprintf("%d months", months) |
| 80 | } |
| 81 | default: |
| 82 | years := int(d.Hours() / 24 / 365) |
| 83 | if years == 1 { |
| 84 | ago = "1 year" |
| 85 | } else { |
| 86 | ago = fmt.Sprintf("%d years", years) |
| 87 | } |
| 88 | } |
| 89 | full := t.Format("2006-01-02 15:04:05 -0700") |
| 90 | return template.HTML(fmt.Sprintf(`<time title="%s">%s</time>`, full, ago)) |
| 91 | }, |
| 92 | "formatDate": func(t time.Time) string { |
| 93 | return t.Format("2006-01-02 15:04:05 -0700") |
| 94 | }, |
| 95 | "add": func(a, b int) int { return a + b }, |
| 96 | "diffFileName": func(f DiffFile) string { |
| 97 | if f.NewName != "" { |
| 98 | return f.NewName |
| 99 | } |
| 100 | return f.OldName |
| 101 | }, |
| 102 | "statusLabel": func(s string) string { |
| 103 | switch s { |
| 104 | case "A": |
| 105 | return "added" |
| 106 | case "D": |
| 107 | return "deleted" |
| 108 | case "M": |
| 109 | return "modified" |
| 110 | case "R": |
| 111 | return "renamed" |
| 112 | } |
| 113 | return s |
| 114 | }, |
| 115 | "formatSize": func(size int64) string { |
| 116 | switch { |
| 117 | case size < 1024: |
| 118 | return fmt.Sprintf("%d B", size) |
| 119 | case size < 1024*1024: |
| 120 | return fmt.Sprintf("%.1f KiB", float64(size)/1024) |
| 121 | default: |
| 122 | return fmt.Sprintf("%.1f MiB", float64(size)/(1024*1024)) |
| 123 | } |
| 124 | }, |
| 125 | "diffBar": func(added, deleted int) template.HTML { |
| 126 | total := added + deleted |
| 127 | if total == 0 { |
| 128 | return "" |
| 129 | } |
| 130 | const maxBlocks = 5 |
| 131 | blocks := maxBlocks |
| 132 | if total < blocks { |
| 133 | blocks = total |
| 134 | } |
| 135 | addBlocks := 0 |
| 136 | if total > 0 { |
| 137 | addBlocks = (added*blocks + total - 1) / total |
| 138 | } |
| 139 | if addBlocks > blocks { |
| 140 | addBlocks = blocks |
| 141 | } |
| 142 | delBlocks := blocks - addBlocks |
| 143 | var b strings.Builder |
| 144 | b.WriteString(`<span class="diffstat-bar">`) |
| 145 | for i := 0; i < addBlocks; i++ { |
| 146 | b.WriteString(`<span class="bar-add"></span>`) |
| 147 | } |
| 148 | for i := 0; i < delBlocks; i++ { |
| 149 | b.WriteString(`<span class="bar-del"></span>`) |
| 150 | } |
| 151 | for i := addBlocks + delBlocks; i < maxBlocks; i++ { |
| 152 | b.WriteString(`<span class="bar-neutral"></span>`) |
| 153 | } |
| 154 | b.WriteString(`</span>`) |
| 155 | return template.HTML(b.String()) |
| 156 | }, |
| 157 | "langClass": func(name string) string { |
| 158 | ext := path.Ext(name) |
| 159 | switch ext { |
| 160 | case ".rad": |
| 161 | return "language-radiance" |
| 162 | case ".ril": |
| 163 | return "language-ril" |
| 164 | } |
| 165 | return "" |
| 166 | }, |
| 167 | "parentPath": func(p string) string { |
| 168 | dir := path.Dir(p) |
| 169 | if dir == "." { |
| 170 | return "" |
| 171 | } |
| 172 | return dir |
| 173 | }, |
| 174 | "indent": func(depth int) string { |
| 175 | if depth == 0 { |
| 176 | return "" |
| 177 | } |
| 178 | return fmt.Sprintf("padding-left: %grem", float64(depth)*1.2) |
| 179 | }, |
| 180 | "formatBody": func(s string) template.HTML { |
| 181 | s = strings.TrimSpace(s) |
| 182 | if s == "" { |
| 183 | return "" |
| 184 | } |
| 185 | var b strings.Builder |
| 186 | // Split into alternating prose / fenced-code-block segments. |
| 187 | for s != "" { |
| 188 | // Find the next opening fence. |
| 189 | openIdx := strings.Index(s, "```") |
| 190 | if openIdx < 0 { |
| 191 | formatParagraphs(&b, s) |
| 192 | break |
| 193 | } |
| 194 | // Prose before the fence. |
| 195 | if openIdx > 0 { |
| 196 | formatParagraphs(&b, s[:openIdx]) |
| 197 | } |
| 198 | // Skip the opening ``` and any language tag on the same line. |
| 199 | rest := s[openIdx+3:] |
| 200 | if nl := strings.Index(rest, "\n"); nl >= 0 { |
| 201 | rest = rest[nl+1:] |
| 202 | } else { |
| 203 | // ``` at end of input with nothing after — treat as prose. |
| 204 | formatParagraphs(&b, s[openIdx:]) |
| 205 | break |
| 206 | } |
| 207 | // Find the closing fence. |
| 208 | closeIdx := strings.Index(rest, "```") |
| 209 | var code string |
| 210 | if closeIdx < 0 { |
| 211 | code = rest |
| 212 | s = "" |
| 213 | } else { |
| 214 | code = rest[:closeIdx] |
| 215 | s = rest[closeIdx+3:] |
| 216 | } |
| 217 | code = strings.TrimRight(code, "\n") |
| 218 | b.WriteString("<pre><code>") |
| 219 | b.WriteString(html.EscapeString(code)) |
| 220 | b.WriteString("</code></pre>") |
| 221 | } |
| 222 | return template.HTML(b.String()) |
| 223 | }, |
| 224 | "discussionPath": func(baseURL, repo string) string { |
| 225 | if repo == "" { |
| 226 | return baseURL + "/discussions" |
| 227 | } |
| 228 | return baseURL + "/" + repo + "/discussions" |
| 229 | }, |
| 230 | "autolink": func(s string) template.HTML { |
| 231 | var b strings.Builder |
| 232 | last := 0 |
| 233 | for _, loc := range urlRe.FindAllStringIndex(s, -1) { |
| 234 | // Strip trailing punctuation. |
| 235 | end := loc[1] |
| 236 | for end > loc[0] && strings.ContainsRune(".,;:!?)]", rune(s[end-1])) { |
| 237 | end-- |
| 238 | } |
| 239 | b.WriteString(html.EscapeString(s[last:loc[0]])) |
| 240 | u := s[loc[0]:end] |
| 241 | b.WriteString(`<a href="`) |
| 242 | b.WriteString(html.EscapeString(u)) |
| 243 | b.WriteString(`">`) |
| 244 | b.WriteString(html.EscapeString(u)) |
| 245 | b.WriteString(`</a>`) |
| 246 | last = end |
| 247 | } |
| 248 | b.WriteString(html.EscapeString(s[last:])) |
| 249 | return template.HTML(b.String()) |
| 250 | }, |
| 251 | } |
| 252 | |
| 253 | var urlRe = regexp.MustCompile(`https?://[^\s<>"'` + "`" + `\x00-\x1f]+`) |
| 254 | |
| 255 | // inlineRe matches (in order): backtick code, angle-bracket links, bare URLs, or *italic*. |
| 256 | var inlineRe = regexp.MustCompile("`" + `([^` + "`" + `\n]+)` + "`" + |
| 257 | `|<(https?://[^\s<>]+)>` + |
| 258 | `|(https?://[^\s<>"'` + "`" + `\x00-\x1f]+)` + |
| 259 | `|\*([^\s*][^*]*[^\s*])\*|\*([^\s*])\*`) |
| 260 | |
| 261 | // formatParagraphs splits prose text on blank lines and writes <p> elements |
| 262 | // with inline formatting into b. |
| 263 | func formatParagraphs(b *strings.Builder, s string) { |
| 264 | for _, p := range strings.Split(s, "\n\n") { |
| 265 | p = strings.TrimSpace(p) |
| 266 | if p == "" { |
| 267 | continue |
| 268 | } |
| 269 | b.WriteString("<p>") |
| 270 | b.WriteString(formatInline(p)) |
| 271 | b.WriteString("</p>") |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // formatInline applies inline formatting to a paragraph of raw text. |
| 276 | // It handles backtick `code`, *italic*, <https://...> links, and bare URLs. |
| 277 | func formatInline(p string) string { |
| 278 | var b strings.Builder |
| 279 | last := 0 |
| 280 | for _, m := range inlineRe.FindAllStringSubmatchIndex(p, -1) { |
| 281 | start, end := m[0], m[1] |
| 282 | b.WriteString(html.EscapeString(p[last:start])) |
| 283 | |
| 284 | switch { |
| 285 | case m[2] >= 0: // backtick code: group 1 |
| 286 | code := p[m[2]:m[3]] |
| 287 | b.WriteString("<code>") |
| 288 | b.WriteString(html.EscapeString(code)) |
| 289 | b.WriteString("</code>") |
| 290 | case m[4] >= 0: // angle-bracket link: group 2 |
| 291 | u := p[m[4]:m[5]] |
| 292 | b.WriteString(`<a href="`) |
| 293 | b.WriteString(html.EscapeString(u)) |
| 294 | b.WriteString(`">`) |
| 295 | b.WriteString(html.EscapeString(u)) |
| 296 | b.WriteString("</a>") |
| 297 | case m[6] >= 0: // bare URL: group 3 |
| 298 | // Strip trailing punctuation. |
| 299 | urlEnd := m[7] |
| 300 | for urlEnd > m[6] && strings.ContainsRune(".,;:!?)]", rune(p[urlEnd-1])) { |
| 301 | urlEnd-- |
| 302 | } |
| 303 | u := p[m[6]:urlEnd] |
| 304 | b.WriteString(`<a href="`) |
| 305 | b.WriteString(html.EscapeString(u)) |
| 306 | b.WriteString(`">`) |
| 307 | b.WriteString(html.EscapeString(u)) |
| 308 | b.WriteString("</a>") |
| 309 | // Anything between stripped punctuation and regex end is plain text. |
| 310 | b.WriteString(html.EscapeString(p[urlEnd:end])) |
| 311 | case m[8] >= 0: // italic (multi-char): group 4 |
| 312 | inner := p[m[8]:m[9]] |
| 313 | b.WriteString("<em>") |
| 314 | b.WriteString(html.EscapeString(inner)) |
| 315 | b.WriteString("</em>") |
| 316 | case m[10] >= 0: // italic (single-char): group 5 |
| 317 | inner := p[m[10]:m[11]] |
| 318 | b.WriteString("<em>") |
| 319 | b.WriteString(html.EscapeString(inner)) |
| 320 | b.WriteString("</em>") |
| 321 | } |
| 322 | last = end |
| 323 | } |
| 324 | b.WriteString(html.EscapeString(p[last:])) |
| 325 | return b.String() |
| 326 | } |
| 327 | |
| 328 | func loadTemplates() (*templateSet, error) { |
| 329 | layoutContent, err := templateFS.ReadFile("templates/layout.html") |
| 330 | if err != nil { |
| 331 | return nil, fmt.Errorf("read layout: %w", err) |
| 332 | } |
| 333 | |
| 334 | pages := []string{"index", "home", "log", "commit", "refs", "error", "discussions", "discussion", "discussion_new", "login"} |
| 335 | ts := &templateSet{ |
| 336 | templates: make(map[string]*template.Template, len(pages)), |
| 337 | } |
| 338 | |
| 339 | for _, page := range pages { |
| 340 | t := template.New("layout").Funcs(funcMap) |
| 341 | t, err = t.Parse(string(layoutContent)) |
| 342 | if err != nil { |
| 343 | return nil, fmt.Errorf("parse layout: %w", err) |
| 344 | } |
| 345 | |
| 346 | pageContent, err := templateFS.ReadFile(fmt.Sprintf("templates/%s.html", page)) |
| 347 | if err != nil { |
| 348 | return nil, fmt.Errorf("read %s: %w", page, err) |
| 349 | } |
| 350 | |
| 351 | t, err = t.Parse(string(pageContent)) |
| 352 | if err != nil { |
| 353 | return nil, fmt.Errorf("parse %s: %w", page, err) |
| 354 | } |
| 355 | |
| 356 | ts.templates[page] = t |
| 357 | } |
| 358 | |
| 359 | return ts, nil |
| 360 | } |
| 361 | |
| 362 | func (ts *templateSet) render(w io.Writer, name string, data any) { |
| 363 | t, ok := ts.templates[name] |
| 364 | if !ok { |
| 365 | fmt.Fprintf(w, "template %q not found", name) |
| 366 | return |
| 367 | } |
| 368 | if err := t.Execute(w, data); err != nil { |
| 369 | fmt.Fprintf(w, "template error: %v", err) |
| 370 | } |
| 371 | } |