larc r30

1258 lines · 36.2 KB Raw
1 package server
2
3 import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "path"
8 "strings"
9
10 "github.com/yuin/goldmark"
11 "github.com/yuin/goldmark/ast"
12 "github.com/yuin/goldmark/extension"
13 "github.com/yuin/goldmark/parser"
14 "github.com/yuin/goldmark/renderer/html"
15 "github.com/yuin/goldmark/text"
16 )
17
18 /* Markdown to HTML rendering for README.md display.
19 * Uses goldmark with GFM extensions (tables, strikethrough, etc.)
20 * Supports relative link transformation for in-repo navigation. */
21
22 var md goldmark.Markdown
23
24 func init() {
25 md = goldmark.New(
26 goldmark.WithExtensions(
27 extension.GFM, // GitHub Flavored Markdown
28 extension.Typographer, // smart quotes, etc.
29 ),
30 goldmark.WithParserOptions(
31 parser.WithAutoHeadingID(), // auto-generate heading IDs
32 ),
33 goldmark.WithRendererOptions(
34 html.WithHardWraps(),
35 html.WithXHTML(),
36 html.WithUnsafe(), // allow raw HTML in markdown
37 ),
38 )
39 }
40
41 /* linkTransformer rewrites relative links to point to repo tree/blob paths.
42 * E.g., ./ide/jetbrains -> /reponame/tree/123/ide/jetbrains */
43 type linkTransformer struct {
44 repoName string
45 revision int64
46 currentPath string // current directory in repo (for resolving relative paths)
47 }
48
49 func (t *linkTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
50 ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
51 if !entering {
52 return ast.WalkContinue, nil
53 }
54
55 switch v := n.(type) {
56 case *ast.Link:
57 t.transformLink(v)
58 case *ast.Image:
59 t.transformImage(v)
60 }
61
62 return ast.WalkContinue, nil
63 })
64 }
65
66 func (t *linkTransformer) transformLink(link *ast.Link) {
67 dest := string(link.Destination)
68
69 /* skip absolute URLs, anchors, and special schemes */
70 if strings.HasPrefix(dest, "http://") ||
71 strings.HasPrefix(dest, "https://") ||
72 strings.HasPrefix(dest, "mailto:") ||
73 strings.HasPrefix(dest, "#") ||
74 strings.HasPrefix(dest, "/") {
75 return
76 }
77
78 /* resolve relative path */
79 resolved := t.resolvePath(dest)
80
81 /* determine if it's likely a directory or file */
82 /* directories: no extension or ends with / */
83 isDir := !strings.Contains(path.Base(resolved), ".") || strings.HasSuffix(dest, "/")
84
85 var newDest string
86 if isDir {
87 newDest = fmt.Sprintf("/%s/tree/%d/%s", t.repoName, t.revision, strings.TrimSuffix(resolved, "/"))
88 } else {
89 newDest = fmt.Sprintf("/%s/blob/%d/%s", t.repoName, t.revision, resolved)
90 }
91
92 link.Destination = []byte(newDest)
93 }
94
95 func (t *linkTransformer) transformImage(img *ast.Image) {
96 dest := string(img.Destination)
97
98 /* skip absolute URLs */
99 if strings.HasPrefix(dest, "http://") ||
100 strings.HasPrefix(dest, "https://") ||
101 strings.HasPrefix(dest, "/") {
102 return
103 }
104
105 /* resolve and point to blob */
106 resolved := t.resolvePath(dest)
107 newDest := fmt.Sprintf("/%s/blob/%d/%s", t.repoName, t.revision, resolved)
108 img.Destination = []byte(newDest)
109 }
110
111 func (t *linkTransformer) resolvePath(dest string) string {
112 /* clean up ./ prefix */
113 dest = strings.TrimPrefix(dest, "./")
114
115 /* resolve relative to current path */
116 if t.currentPath != "" {
117 dest = path.Join(t.currentPath, dest)
118 }
119
120 /* clean up path */
121 dest = path.Clean(dest)
122 dest = strings.TrimPrefix(dest, "/")
123
124 return dest
125 }
126
127 const pageTemplate = `<!DOCTYPE html>
128 <html lang="en">
129 <head>
130 <meta charset="UTF-8">
131 <meta name="viewport" content="width=device-width, initial-scale=1.0">
132 <title>{{.Title}} - larc</title>
133 <style>
134 :root {
135 --bg: #0d1117;
136 --fg: #c9d1d9;
137 --border: #30363d;
138 --link: #58a6ff;
139 --code-bg: #161b22;
140 --header-bg: #161b22;
141 }
142 * { box-sizing: border-box; }
143 body {
144 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
145 background: var(--bg);
146 color: var(--fg);
147 line-height: 1.6;
148 margin: 0;
149 padding: 0;
150 }
151 .header {
152 background: var(--header-bg);
153 border-bottom: 1px solid var(--border);
154 padding: 16px 24px;
155 }
156 .header h1 {
157 margin: 0;
158 font-size: 20px;
159 font-weight: 600;
160 }
161 .header h1 a {
162 color: var(--fg);
163 text-decoration: none;
164 }
165 .header .rev {
166 color: #8b949e;
167 font-size: 14px;
168 margin-left: 8px;
169 }
170 .container {
171 max-width: 1012px;
172 margin: 0 auto;
173 padding: 24px;
174 }
175 .readme {
176 background: var(--header-bg);
177 border: 1px solid var(--border);
178 border-radius: 6px;
179 padding: 32px;
180 }
181 .readme h1, .readme h2, .readme h3 {
182 border-bottom: 1px solid var(--border);
183 padding-bottom: 8px;
184 margin-top: 24px;
185 }
186 .readme h1:first-child { margin-top: 0; }
187 .readme a { color: var(--link); }
188 .readme code {
189 background: var(--code-bg);
190 padding: 2px 6px;
191 border-radius: 3px;
192 font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace;
193 font-size: 85%;
194 }
195 .readme pre {
196 background: var(--code-bg);
197 padding: 16px;
198 border-radius: 6px;
199 overflow-x: auto;
200 }
201 .readme pre code {
202 padding: 0;
203 background: none;
204 }
205 .readme table {
206 border-collapse: collapse;
207 width: 100%;
208 }
209 .readme th, .readme td {
210 border: 1px solid var(--border);
211 padding: 8px 12px;
212 text-align: left;
213 }
214 .readme th {
215 background: var(--code-bg);
216 }
217 .readme blockquote {
218 border-left: 4px solid var(--border);
219 margin: 0;
220 padding-left: 16px;
221 color: #8b949e;
222 }
223 .readme img {
224 max-width: 100%;
225 }
226 .readme ul, .readme ol {
227 padding-left: 24px;
228 }
229 .nav {
230 margin-bottom: 16px;
231 font-size: 14px;
232 }
233 .nav a {
234 color: var(--link);
235 text-decoration: none;
236 margin-right: 16px;
237 }
238 .nav a:hover {
239 text-decoration: underline;
240 }
241 </style>
242 </head>
243 <body>
244 <div class="header">
245 <h1>
246 <a href="/{{.RepoName}}">{{.RepoName}}</a>
247 {{if .Revision}}<span class="rev">r{{.Revision}}</span>{{end}}
248 </h1>
249 </div>
250 <div class="container">
251 <div class="nav">
252 <a href="/{{.RepoName}}">README</a>
253 <a href="/{{.RepoName}}/tree/{{.Revision}}/">Files</a>
254 <a href="/{{.RepoName}}/log">Log</a>
255 </div>
256 <div class="readme">
257 {{.Content}}
258 </div>
259 </div>
260 </body>
261 </html>`
262
263 var tmpl *template.Template
264
265 func init() {
266 tmpl = template.Must(template.New("page").Parse(pageTemplate))
267 }
268
269 // PageData contains data for rendering a page
270 type PageData struct {
271 Title string
272 RepoName string
273 Revision int64
274 Content template.HTML
275 }
276
277 // RenderMarkdown converts markdown to HTML
278 func RenderMarkdown(markdown []byte) ([]byte, error) {
279 var buf bytes.Buffer
280 if err := md.Convert(markdown, &buf); err != nil {
281 return nil, fmt.Errorf("render markdown: %w", err)
282 }
283 return buf.Bytes(), nil
284 }
285
286 // RenderMarkdownWithLinks converts markdown to HTML with relative link transformation.
287 // Transforms links like ./path/to/dir into /repo/tree/rev/path/to/dir
288 func RenderMarkdownWithLinks(markdown []byte, repoName string, revision int64, currentPath string) ([]byte, error) {
289 transformer := &linkTransformer{
290 repoName: repoName,
291 revision: revision,
292 currentPath: currentPath,
293 }
294
295 mdWithLinks := goldmark.New(
296 goldmark.WithExtensions(
297 extension.GFM,
298 extension.Typographer,
299 ),
300 goldmark.WithParserOptions(
301 parser.WithAutoHeadingID(),
302 ),
303 goldmark.WithRendererOptions(
304 html.WithHardWraps(),
305 html.WithXHTML(),
306 html.WithUnsafe(),
307 ),
308 )
309
310 /* parse first, transform, then render */
311 reader := text.NewReader(markdown)
312 doc := mdWithLinks.Parser().Parse(reader)
313
314 /* apply link transformation */
315 transformer.Transform(doc.(*ast.Document), reader, nil)
316
317 /* render to HTML */
318 var buf bytes.Buffer
319 if err := mdWithLinks.Renderer().Render(&buf, markdown, doc); err != nil {
320 return nil, fmt.Errorf("render markdown: %w", err)
321 }
322
323 return buf.Bytes(), nil
324 }
325
326 // RenderPage renders a full HTML page
327 func RenderPage(data *PageData) ([]byte, error) {
328 var buf bytes.Buffer
329 if err := tmpl.Execute(&buf, data); err != nil {
330 return nil, fmt.Errorf("render page: %w", err)
331 }
332 return buf.Bytes(), nil
333 }
334
335 // RenderReadme renders README.md as a full HTML page.
336 // Transforms relative links to point to repo tree/blob paths.
337 func RenderReadme(repoName string, revision int64, readmeContent []byte) ([]byte, error) {
338 /* use link transformer to rewrite relative paths */
339 htmlContent, err := RenderMarkdownWithLinks(readmeContent, repoName, revision, "")
340 if err != nil {
341 return nil, err
342 }
343
344 data := &PageData{
345 Title: repoName,
346 RepoName: repoName,
347 Revision: revision,
348 Content: template.HTML(htmlContent),
349 }
350
351 return RenderPage(data)
352 }
353
354 const treeTemplate = `<!DOCTYPE html>
355 <html lang="en">
356 <head>
357 <meta charset="UTF-8">
358 <meta name="viewport" content="width=device-width, initial-scale=1.0">
359 <title>{{.RepoName}} - Files</title>
360 <style>
361 :root {
362 --bg: #0d1117;
363 --fg: #c9d1d9;
364 --fg-muted: #8b949e;
365 --border: #30363d;
366 --link: #58a6ff;
367 --header-bg: #161b22;
368 --row-hover: #161b22;
369 }
370 * { box-sizing: border-box; }
371 body {
372 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
373 background: var(--bg);
374 color: var(--fg);
375 line-height: 1.5;
376 margin: 0;
377 }
378 .header {
379 background: var(--header-bg);
380 border-bottom: 1px solid var(--border);
381 padding: 16px 24px;
382 }
383 .header h1 { margin: 0; font-size: 20px; }
384 .header h1 a { color: var(--fg); text-decoration: none; }
385 .header .rev { color: var(--fg-muted); font-size: 14px; margin-left: 8px; }
386 .container { max-width: 1012px; margin: 0 auto; padding: 24px; }
387 .nav { margin-bottom: 16px; font-size: 14px; }
388 .nav a { color: var(--link); text-decoration: none; margin-right: 16px; }
389 .nav a:hover { text-decoration: underline; }
390 .nav a.active { color: var(--fg); font-weight: 600; }
391 .breadcrumb { margin-bottom: 16px; font-size: 14px; }
392 .breadcrumb a { color: var(--link); text-decoration: none; }
393 .breadcrumb span { color: var(--fg-muted); margin: 0 4px; }
394 .file-tree {
395 background: var(--header-bg);
396 border: 1px solid var(--border);
397 border-radius: 6px;
398 overflow: hidden;
399 }
400 .file-row {
401 display: flex;
402 align-items: center;
403 padding: 8px 16px;
404 border-bottom: 1px solid var(--border);
405 font-size: 14px;
406 }
407 .file-row:last-child { border-bottom: none; }
408 .file-row:hover { background: var(--row-hover); }
409 .file-icon {
410 width: 20px;
411 margin-right: 8px;
412 text-align: center;
413 color: var(--fg-muted);
414 }
415 .file-name { flex: 1; }
416 .file-name a { color: var(--fg); text-decoration: none; }
417 .file-name a:hover { color: var(--link); text-decoration: underline; }
418 .file-size { color: var(--fg-muted); font-size: 12px; min-width: 80px; text-align: right; }
419 .dir-icon::before { content: "F"; }
420 .file-icon-default::before { content: "D"; }
421 .empty { padding: 32px; text-align: center; color: var(--fg-muted); }
422 </style>
423 </head>
424 <body>
425 <div class="header">
426 <h1>
427 <a href="/{{.RepoName}}">{{.RepoName}}</a>
428 <span class="rev">r{{.Revision}}</span>
429 </h1>
430 </div>
431 <div class="container">
432 <div class="nav">
433 <a href="/{{.RepoName}}">README</a>
434 <a href="/{{.RepoName}}/tree/{{.Revision}}/" class="active">Files</a>
435 <a href="/{{.RepoName}}/log">Log</a>
436 </div>
437 {{if .Path}}
438 <div class="breadcrumb">
439 <a href="/{{.RepoName}}/tree/{{.Revision}}/">root</a>
440 {{range .Breadcrumbs}}<span>/</span><a href="{{.URL}}">{{.Name}}</a>{{end}}
441 </div>
442 {{end}}
443 <div class="file-tree">
444 {{if .Entries}}
445 {{range .Dirs}}
446 <div class="file-row">
447 <span class="file-icon dir-icon"></span>
448 <span class="file-name"><a href="/{{$.RepoName}}/tree/{{$.Revision}}/{{.Path}}">{{.Name}}/</a></span>
449 <span class="file-size">—</span>
450 </div>
451 {{end}}
452 {{range .Files}}
453 <div class="file-row">
454 <span class="file-icon file-icon-default"></span>
455 <span class="file-name"><a href="/{{$.RepoName}}/blob/{{$.Revision}}/{{.Path}}">{{.Name}}</a></span>
456 <span class="file-size">{{.SizeStr}}</span>
457 </div>
458 {{end}}
459 {{else}}
460 <div class="empty">Empty directory</div>
461 {{end}}
462 </div>
463 </div>
464 </body>
465 </html>`
466
467 // TreeEntry for template
468 type TreeEntryView struct {
469 Name string
470 Path string
471 Size int64
472 SizeStr string
473 IsDir bool
474 }
475
476 // Breadcrumb for navigation
477 type Breadcrumb struct {
478 Name string
479 URL string
480 }
481
482 // TreeData for tree template
483 type TreeData struct {
484 RepoName string
485 Revision int64
486 Path string
487 Breadcrumbs []Breadcrumb
488 Entries bool
489 Dirs []TreeEntryView
490 Files []TreeEntryView
491 }
492
493 var treeTmpl *template.Template
494
495 func init() {
496 treeTmpl = template.Must(template.New("tree").Parse(treeTemplate))
497 }
498
499 func formatSize(size int64) string {
500 if size < 1024 {
501 return fmt.Sprintf("%d B", size)
502 } else if size < 1024*1024 {
503 return fmt.Sprintf("%.1f KB", float64(size)/1024)
504 } else if size < 1024*1024*1024 {
505 return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024))
506 }
507 return fmt.Sprintf("%.1f GB", float64(size)/(1024*1024*1024))
508 }
509
510 // RenderTree renders file tree as HTML
511 func RenderTree(data *TreeData) ([]byte, error) {
512 var buf bytes.Buffer
513 if err := treeTmpl.Execute(&buf, data); err != nil {
514 return nil, fmt.Errorf("render tree: %w", err)
515 }
516 return buf.Bytes(), nil
517 }
518
519 const logTemplate = `<!DOCTYPE html>
520 <html lang="en">
521 <head>
522 <meta charset="UTF-8">
523 <meta name="viewport" content="width=device-width, initial-scale=1.0">
524 <title>{{.RepoName}} - Log</title>
525 <style>
526 :root {
527 --bg: #0d1117;
528 --fg: #c9d1d9;
529 --fg-muted: #8b949e;
530 --border: #30363d;
531 --link: #58a6ff;
532 --header-bg: #161b22;
533 --rev-color: #f0883e;
534 --branch-bg: #388bfd26;
535 --branch-color: #58a6ff;
536 }
537 * { box-sizing: border-box; }
538 body {
539 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
540 background: var(--bg);
541 color: var(--fg);
542 line-height: 1.5;
543 margin: 0;
544 }
545 .header {
546 background: var(--header-bg);
547 border-bottom: 1px solid var(--border);
548 padding: 16px 24px;
549 }
550 .header h1 { margin: 0; font-size: 20px; }
551 .header h1 a { color: var(--fg); text-decoration: none; }
552 .container { max-width: 1012px; margin: 0 auto; padding: 24px; }
553 .nav { margin-bottom: 16px; font-size: 14px; }
554 .nav a { color: var(--link); text-decoration: none; margin-right: 16px; }
555 .nav a:hover { text-decoration: underline; }
556 .nav a.active { color: var(--fg); font-weight: 600; }
557 .commits {
558 background: var(--header-bg);
559 border: 1px solid var(--border);
560 border-radius: 6px;
561 overflow: hidden;
562 }
563 .commit {
564 padding: 16px;
565 border-bottom: 1px solid var(--border);
566 }
567 .commit:last-child { border-bottom: none; }
568 .commit-header {
569 display: flex;
570 align-items: center;
571 margin-bottom: 8px;
572 }
573 .commit-rev {
574 font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace;
575 font-weight: 600;
576 color: var(--rev-color);
577 margin-right: 12px;
578 }
579 .commit-rev a { color: inherit; text-decoration: none; }
580 .commit-rev a:hover { text-decoration: underline; }
581 .commit-branch {
582 background: var(--branch-bg);
583 color: var(--branch-color);
584 padding: 2px 8px;
585 border-radius: 12px;
586 font-size: 12px;
587 margin-right: 12px;
588 }
589 .commit-author {
590 color: var(--fg);
591 font-weight: 500;
592 }
593 .commit-date {
594 color: var(--fg-muted);
595 font-size: 12px;
596 margin-left: auto;
597 }
598 .commit-message {
599 color: var(--fg);
600 font-size: 14px;
601 }
602 .empty { padding: 32px; text-align: center; color: var(--fg-muted); }
603 .pagination {
604 margin-top: 16px;
605 text-align: center;
606 }
607 .pagination a {
608 color: var(--link);
609 text-decoration: none;
610 padding: 8px 16px;
611 border: 1px solid var(--border);
612 border-radius: 6px;
613 margin: 0 4px;
614 }
615 .pagination a:hover { background: var(--header-bg); }
616 </style>
617 </head>
618 <body>
619 <div class="header">
620 <h1><a href="/{{.RepoName}}">{{.RepoName}}</a></h1>
621 </div>
622 <div class="container">
623 <div class="nav">
624 <a href="/{{.RepoName}}">README</a>
625 <a href="/{{.RepoName}}/tree/{{.LatestRev}}/">Files</a>
626 <a href="/{{.RepoName}}/log" class="active">Log</a>
627 </div>
628 <div class="commits">
629 {{if .Commits}}
630 {{range .Commits}}
631 <div class="commit">
632 <div class="commit-header">
633 <span class="commit-rev"><a href="/{{$.RepoName}}/tree/{{.Number}}/">r{{.Number}}</a></span>
634 <span class="commit-branch">{{.Branch}}</span>
635 <span class="commit-author">{{.Author}}</span>
636 <span class="commit-date">{{.DateStr}}</span>
637 </div>
638 <div class="commit-message">{{.Message}}</div>
639 </div>
640 {{end}}
641 {{else}}
642 <div class="empty">No commits yet</div>
643 {{end}}
644 </div>
645 {{if or .HasPrev .HasNext}}
646 <div class="pagination">
647 {{if .HasPrev}}<a href="/{{.RepoName}}/log?offset={{.PrevOffset}}"><- Newer</a>{{end}}
648 {{if .HasNext}}<a href="/{{.RepoName}}/log?offset={{.NextOffset}}">Older -></a>{{end}}
649 </div>
650 {{end}}
651 </div>
652 </body>
653 </html>`
654
655 // CommitView for template
656 type CommitView struct {
657 Number int64
658 Branch string
659 Author string
660 Message string
661 DateStr string
662 }
663
664 // LogData for log template
665 type LogData struct {
666 RepoName string
667 LatestRev int64
668 Commits []CommitView
669 HasPrev bool
670 HasNext bool
671 PrevOffset int
672 NextOffset int
673 }
674
675 var logTmpl *template.Template
676
677 func init() {
678 logTmpl = template.Must(template.New("log").Parse(logTemplate))
679 }
680
681 // RenderLog renders commit log as HTML
682 func RenderLog(data *LogData) ([]byte, error) {
683 var buf bytes.Buffer
684 if err := logTmpl.Execute(&buf, data); err != nil {
685 return nil, fmt.Errorf("render log: %w", err)
686 }
687 return buf.Bytes(), nil
688 }
689
690 /* Home page templates for server index */
691
692 const homePageTemplate = `<!DOCTYPE html>
693 <html lang="en">
694 <head>
695 <meta charset="UTF-8">
696 <meta name="viewport" content="width=device-width, initial-scale=1.0">
697 <title>larc</title>
698 <style>
699 :root {
700 --bg: #0d1117;
701 --fg: #c9d1d9;
702 --fg-muted: #8b949e;
703 --border: #30363d;
704 --link: #58a6ff;
705 --code-bg: #161b22;
706 --header-bg: #161b22;
707 }
708 * { box-sizing: border-box; }
709 body {
710 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
711 background: var(--bg);
712 color: var(--fg);
713 line-height: 1.6;
714 margin: 0;
715 padding: 0;
716 }
717 .header {
718 background: var(--header-bg);
719 border-bottom: 1px solid var(--border);
720 padding: 16px 24px;
721 }
722 .header h1 {
723 margin: 0;
724 font-size: 20px;
725 font-weight: 600;
726 }
727 .header h1 a {
728 color: var(--fg);
729 text-decoration: none;
730 }
731 .container {
732 max-width: 1012px;
733 margin: 0 auto;
734 padding: 24px;
735 }
736 .readme {
737 background: var(--header-bg);
738 border: 1px solid var(--border);
739 border-radius: 6px;
740 padding: 32px;
741 margin-bottom: 24px;
742 }
743 .readme h1, .readme h2, .readme h3 {
744 border-bottom: 1px solid var(--border);
745 padding-bottom: 8px;
746 margin-top: 24px;
747 }
748 .readme h1:first-child { margin-top: 0; }
749 .readme a { color: var(--link); }
750 .readme code {
751 background: var(--code-bg);
752 padding: 2px 6px;
753 border-radius: 3px;
754 font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace;
755 font-size: 85%;
756 }
757 .readme pre {
758 background: var(--code-bg);
759 padding: 16px;
760 border-radius: 6px;
761 overflow-x: auto;
762 }
763 .readme pre code {
764 padding: 0;
765 background: none;
766 }
767 .readme table {
768 border-collapse: collapse;
769 width: 100%;
770 }
771 .readme th, .readme td {
772 border: 1px solid var(--border);
773 padding: 8px 12px;
774 text-align: left;
775 }
776 .readme th {
777 background: var(--code-bg);
778 }
779 .readme blockquote {
780 border-left: 4px solid var(--border);
781 margin: 0;
782 padding-left: 16px;
783 color: #8b949e;
784 }
785 .readme img {
786 max-width: 100%;
787 }
788 .readme ul, .readme ol {
789 padding-left: 24px;
790 }
791 .repos-section h2 {
792 font-size: 16px;
793 margin: 0 0 16px 0;
794 color: var(--fg-muted);
795 }
796 .repo-list {
797 background: var(--header-bg);
798 border: 1px solid var(--border);
799 border-radius: 6px;
800 overflow: hidden;
801 }
802 .repo-item {
803 display: flex;
804 align-items: center;
805 padding: 12px 16px;
806 border-bottom: 1px solid var(--border);
807 }
808 .repo-item:last-child { border-bottom: none; }
809 .repo-item:hover { background: var(--bg); }
810 .repo-name {
811 font-weight: 500;
812 }
813 .repo-name a {
814 color: var(--link);
815 text-decoration: none;
816 }
817 .repo-name a:hover {
818 text-decoration: underline;
819 }
820 .repo-desc {
821 color: var(--fg-muted);
822 font-size: 14px;
823 margin-left: 16px;
824 }
825 .repo-badge {
826 margin-left: auto;
827 font-size: 12px;
828 padding: 2px 8px;
829 border-radius: 12px;
830 background: #388bfd26;
831 color: var(--link);
832 }
833 </style>
834 </head>
835 <body>
836 <div class="header">
837 <h1><a href="/">larc</a></h1>
838 </div>
839 <div class="container">
840 <div class="readme">
841 {{.Content}}
842 </div>
843 {{if .Repos}}
844 <div class="repos-section">
845 <h2>Repositories</h2>
846 <div class="repo-list">
847 {{range .Repos}}
848 <div class="repo-item">
849 <span class="repo-name"><a href="/{{.Name}}">{{.Name}}</a></span>
850 {{if .Description}}<span class="repo-desc">{{.Description}}</span>{{end}}
851 {{if .Public}}<span class="repo-badge">public</span>{{end}}
852 </div>
853 {{end}}
854 </div>
855 </div>
856 {{end}}
857 </div>
858 </body>
859 </html>`
860
861 const repoListTemplate = `<!DOCTYPE html>
862 <html lang="en">
863 <head>
864 <meta charset="UTF-8">
865 <meta name="viewport" content="width=device-width, initial-scale=1.0">
866 <title>larc</title>
867 <style>
868 :root {
869 --bg: #0d1117;
870 --fg: #c9d1d9;
871 --fg-muted: #8b949e;
872 --border: #30363d;
873 --link: #58a6ff;
874 --header-bg: #161b22;
875 }
876 * { box-sizing: border-box; }
877 body {
878 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
879 background: var(--bg);
880 color: var(--fg);
881 line-height: 1.6;
882 margin: 0;
883 padding: 0;
884 }
885 .header {
886 background: var(--header-bg);
887 border-bottom: 1px solid var(--border);
888 padding: 16px 24px;
889 }
890 .header h1 {
891 margin: 0;
892 font-size: 20px;
893 font-weight: 600;
894 }
895 .header h1 a {
896 color: var(--fg);
897 text-decoration: none;
898 }
899 .container {
900 max-width: 1012px;
901 margin: 0 auto;
902 padding: 24px;
903 }
904 .repos-section h2 {
905 font-size: 18px;
906 margin: 0 0 16px 0;
907 }
908 .repo-list {
909 background: var(--header-bg);
910 border: 1px solid var(--border);
911 border-radius: 6px;
912 overflow: hidden;
913 }
914 .repo-item {
915 display: flex;
916 align-items: center;
917 padding: 12px 16px;
918 border-bottom: 1px solid var(--border);
919 }
920 .repo-item:last-child { border-bottom: none; }
921 .repo-item:hover { background: var(--bg); }
922 .repo-name {
923 font-weight: 500;
924 }
925 .repo-name a {
926 color: var(--link);
927 text-decoration: none;
928 }
929 .repo-name a:hover {
930 text-decoration: underline;
931 }
932 .repo-desc {
933 color: var(--fg-muted);
934 font-size: 14px;
935 margin-left: 16px;
936 }
937 .repo-badge {
938 margin-left: auto;
939 font-size: 12px;
940 padding: 2px 8px;
941 border-radius: 12px;
942 background: #388bfd26;
943 color: var(--link);
944 }
945 .empty {
946 padding: 32px;
947 text-align: center;
948 color: var(--fg-muted);
949 }
950 </style>
951 </head>
952 <body>
953 <div class="header">
954 <h1><a href="/">larc</a></h1>
955 </div>
956 <div class="container">
957 <div class="repos-section">
958 <h2>Repositories</h2>
959 <div class="repo-list">
960 {{if .Repos}}
961 {{range .Repos}}
962 <div class="repo-item">
963 <span class="repo-name"><a href="/{{.Name}}">{{.Name}}</a></span>
964 {{if .Description}}<span class="repo-desc">{{.Description}}</span>{{end}}
965 {{if .Public}}<span class="repo-badge">public</span>{{end}}
966 </div>
967 {{end}}
968 {{else}}
969 <div class="empty">No repositories configured</div>
970 {{end}}
971 </div>
972 </div>
973 </div>
974 </body>
975 </html>`
976
977 // HomePageData for home page template
978 type HomePageData struct {
979 Content template.HTML
980 Repos []RepoConfig
981 }
982
983 // RepoListData for repo list template
984 type RepoListData struct {
985 Repos []RepoConfig
986 }
987
988 var homePageTmpl *template.Template
989 var repoListTmpl *template.Template
990
991 func init() {
992 homePageTmpl = template.Must(template.New("homepage").Parse(homePageTemplate))
993 repoListTmpl = template.Must(template.New("repolist").Parse(repoListTemplate))
994 }
995
996 // RenderHomePage renders home page with custom README.md
997 func RenderHomePage(readmeContent []byte, repos []RepoConfig) ([]byte, error) {
998 htmlContent, err := RenderMarkdown(readmeContent)
999 if err != nil {
1000 return nil, err
1001 }
1002
1003 data := &HomePageData{
1004 Content: template.HTML(htmlContent),
1005 Repos: repos,
1006 }
1007
1008 var buf bytes.Buffer
1009 if err := homePageTmpl.Execute(&buf, data); err != nil {
1010 return nil, fmt.Errorf("render home page: %w", err)
1011 }
1012 return buf.Bytes(), nil
1013 }
1014
1015 // RenderRepoList renders repository list page
1016 func RenderRepoList(repos []RepoConfig) ([]byte, error) {
1017 data := &RepoListData{
1018 Repos: repos,
1019 }
1020
1021 var buf bytes.Buffer
1022 if err := repoListTmpl.Execute(&buf, data); err != nil {
1023 return nil, fmt.Errorf("render repo list: %w", err)
1024 }
1025 return buf.Bytes(), nil
1026 }
1027
1028 /* Blob view template with syntax highlighting and line anchors.
1029 * Uses highlight.js for syntax highlighting.
1030 * Line numbers are clickable and update URL hash (e.g. #L32). */
1031 const blobTemplate = `<!DOCTYPE html>
1032 <html lang="en">
1033 <head>
1034 <meta charset="UTF-8">
1035 <meta name="viewport" content="width=device-width, initial-scale=1.0">
1036 <title>{{.FileName}} - {{.RepoName}}</title>
1037 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
1038 <style>
1039 :root {
1040 --bg: #0d1117;
1041 --fg: #c9d1d9;
1042 --fg-muted: #8b949e;
1043 --border: #30363d;
1044 --link: #58a6ff;
1045 --header-bg: #161b22;
1046 --line-highlight: #264f78;
1047 --line-num: #6e7681;
1048 }
1049 * { box-sizing: border-box; }
1050 body {
1051 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
1052 background: var(--bg);
1053 color: var(--fg);
1054 line-height: 1.5;
1055 margin: 0;
1056 }
1057 .header {
1058 background: var(--header-bg);
1059 border-bottom: 1px solid var(--border);
1060 padding: 16px 24px;
1061 }
1062 .header h1 { margin: 0; font-size: 20px; }
1063 .header h1 a { color: var(--fg); text-decoration: none; }
1064 .header .rev { color: var(--fg-muted); font-size: 14px; margin-left: 8px; }
1065 .container { max-width: 1280px; margin: 0 auto; padding: 24px; }
1066 .nav { margin-bottom: 16px; font-size: 14px; }
1067 .nav a { color: var(--link); text-decoration: none; margin-right: 16px; }
1068 .nav a:hover { text-decoration: underline; }
1069 .breadcrumb { margin-bottom: 16px; font-size: 14px; }
1070 .breadcrumb a { color: var(--link); text-decoration: none; }
1071 .breadcrumb span { color: var(--fg-muted); margin: 0 4px; }
1072 .file-header {
1073 background: var(--header-bg);
1074 border: 1px solid var(--border);
1075 border-bottom: none;
1076 border-radius: 6px 6px 0 0;
1077 padding: 8px 16px;
1078 font-size: 14px;
1079 display: flex;
1080 align-items: center;
1081 justify-content: space-between;
1082 }
1083 .file-info { color: var(--fg-muted); }
1084 .file-actions a {
1085 color: var(--link);
1086 text-decoration: none;
1087 margin-left: 16px;
1088 font-size: 12px;
1089 }
1090 .file-actions a:hover { text-decoration: underline; }
1091 .code-container {
1092 background: var(--header-bg);
1093 border: 1px solid var(--border);
1094 border-radius: 0 0 6px 6px;
1095 overflow-x: auto;
1096 }
1097 .code-table {
1098 width: 100%;
1099 border-collapse: collapse;
1100 font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace;
1101 font-size: 13px;
1102 line-height: 1.45;
1103 }
1104 .code-table td {
1105 padding: 0;
1106 vertical-align: top;
1107 }
1108 .line-num {
1109 width: 1%;
1110 min-width: 50px;
1111 padding: 0 10px;
1112 text-align: right;
1113 user-select: none;
1114 color: var(--line-num);
1115 border-right: 1px solid var(--border);
1116 }
1117 .line-num a {
1118 color: inherit;
1119 text-decoration: none;
1120 display: block;
1121 padding: 0 4px;
1122 }
1123 .line-num a:hover {
1124 color: var(--fg);
1125 }
1126 .line-code {
1127 padding: 0 16px;
1128 white-space: pre;
1129 }
1130 .line-code code {
1131 background: none !important;
1132 padding: 0 !important;
1133 }
1134 tr.highlighted {
1135 background: var(--line-highlight) !important;
1136 }
1137 tr.highlighted .line-num {
1138 background: var(--line-highlight);
1139 }
1140 /* override hljs background */
1141 .hljs { background: transparent !important; }
1142 pre { margin: 0; }
1143 </style>
1144 </head>
1145 <body>
1146 <div class="header">
1147 <h1>
1148 <a href="/{{.RepoName}}">{{.RepoName}}</a>
1149 <span class="rev">r{{.Revision}}</span>
1150 </h1>
1151 </div>
1152 <div class="container">
1153 <div class="nav">
1154 <a href="/{{.RepoName}}">README</a>
1155 <a href="/{{.RepoName}}/tree/{{.Revision}}/">Files</a>
1156 <a href="/{{.RepoName}}/log">Log</a>
1157 </div>
1158 {{if .Breadcrumbs}}
1159 <div class="breadcrumb">
1160 <a href="/{{.RepoName}}/tree/{{.Revision}}/">root</a>
1161 {{range .Breadcrumbs}}<span>/</span><a href="{{.URL}}">{{.Name}}</a>{{end}}
1162 </div>
1163 {{end}}
1164 <div class="file-header">
1165 <span class="file-info">{{.LineCount}} lines · {{.SizeStr}}</span>
1166 <span class="file-actions">
1167 <a href="/{{.RepoName}}/raw/{{.Revision}}/{{.FilePath}}">Raw</a>
1168 </span>
1169 </div>
1170 <div class="code-container">
1171 <table class="code-table">
1172 <tbody>
1173 {{range .Lines}}
1174 <tr id="L{{.Num}}">
1175 <td class="line-num"><a href="#L{{.Num}}">{{.Num}}</a></td>
1176 <td class="line-code"><code>{{.Content}}</code></td>
1177 </tr>
1178 {{end}}
1179 </tbody>
1180 </table>
1181 </div>
1182 </div>
1183 <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
1184 <script>
1185 /* highlight code */
1186 document.querySelectorAll('.line-code code').forEach(el => {
1187 hljs.highlightElement(el);
1188 });
1189
1190 /* handle line highlighting from URL hash */
1191 function highlightLine() {
1192 /* clear previous */
1193 document.querySelectorAll('tr.highlighted').forEach(tr => {
1194 tr.classList.remove('highlighted');
1195 });
1196
1197 const hash = window.location.hash;
1198 if (hash && hash.startsWith('#L')) {
1199 const lineNum = hash.substring(2);
1200 const row = document.getElementById('L' + lineNum);
1201 if (row) {
1202 row.classList.add('highlighted');
1203 row.scrollIntoView({ block: 'center' });
1204 }
1205 }
1206 }
1207
1208 /* highlight on load and hash change */
1209 highlightLine();
1210 window.addEventListener('hashchange', highlightLine);
1211
1212 /* update URL when clicking line numbers */
1213 document.querySelectorAll('.line-num a').forEach(a => {
1214 a.addEventListener('click', function(e) {
1215 e.preventDefault();
1216 const href = this.getAttribute('href');
1217 history.pushState(null, null, href);
1218 highlightLine();
1219 });
1220 });
1221 </script>
1222 </body>
1223 </html>`
1224
1225 // BlobLine represents a line of code
1226 type BlobLine struct {
1227 Num int
1228 Content string
1229 }
1230
1231 // BlobData for blob view template
1232 type BlobData struct {
1233 RepoName string
1234 Revision int64
1235 FilePath string
1236 FileName string
1237 Breadcrumbs []Breadcrumb
1238 Lines []BlobLine
1239 LineCount int
1240 SizeStr string
1241 Language string
1242 }
1243
1244 var blobTmpl *template.Template
1245
1246 func init() {
1247 blobTmpl = template.Must(template.New("blob").Parse(blobTemplate))
1248 }
1249
1250 // RenderBlob renders file content with syntax highlighting
1251 func RenderBlob(data *BlobData) ([]byte, error) {
1252 var buf bytes.Buffer
1253 if err := blobTmpl.Execute(&buf, data); err != nil {
1254 return nil, fmt.Errorf("render blob: %w", err)
1255 }
1256 return buf.Bytes(), nil
1257 }
1258