larc r4

556 lines · 16.0 KB Raw
1 package server
2
3 import (
4 "bytes"
5 "fmt"
6 "html/template"
7
8 "github.com/yuin/goldmark"
9 "github.com/yuin/goldmark/extension"
10 "github.com/yuin/goldmark/parser"
11 "github.com/yuin/goldmark/renderer/html"
12 )
13
14 /* Markdown to HTML rendering for README.md display.
15 * Uses goldmark with GFM extensions (tables, strikethrough, etc.) */
16
17 var md goldmark.Markdown
18
19 func init() {
20 md = goldmark.New(
21 goldmark.WithExtensions(
22 extension.GFM, // GitHub Flavored Markdown
23 extension.Typographer, // smart quotes, etc.
24 ),
25 goldmark.WithParserOptions(
26 parser.WithAutoHeadingID(), // auto-generate heading IDs
27 ),
28 goldmark.WithRendererOptions(
29 html.WithHardWraps(),
30 html.WithXHTML(),
31 html.WithUnsafe(), // allow raw HTML in markdown
32 ),
33 )
34 }
35
36 const pageTemplate = `<!DOCTYPE html>
37 <html lang="en">
38 <head>
39 <meta charset="UTF-8">
40 <meta name="viewport" content="width=device-width, initial-scale=1.0">
41 <title>{{.Title}} - larc</title>
42 <style>
43 :root {
44 --bg: #0d1117;
45 --fg: #c9d1d9;
46 --border: #30363d;
47 --link: #58a6ff;
48 --code-bg: #161b22;
49 --header-bg: #161b22;
50 }
51 * { box-sizing: border-box; }
52 body {
53 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
54 background: var(--bg);
55 color: var(--fg);
56 line-height: 1.6;
57 margin: 0;
58 padding: 0;
59 }
60 .header {
61 background: var(--header-bg);
62 border-bottom: 1px solid var(--border);
63 padding: 16px 24px;
64 }
65 .header h1 {
66 margin: 0;
67 font-size: 20px;
68 font-weight: 600;
69 }
70 .header h1 a {
71 color: var(--fg);
72 text-decoration: none;
73 }
74 .header .rev {
75 color: #8b949e;
76 font-size: 14px;
77 margin-left: 8px;
78 }
79 .container {
80 max-width: 1012px;
81 margin: 0 auto;
82 padding: 24px;
83 }
84 .readme {
85 background: var(--header-bg);
86 border: 1px solid var(--border);
87 border-radius: 6px;
88 padding: 32px;
89 }
90 .readme h1, .readme h2, .readme h3 {
91 border-bottom: 1px solid var(--border);
92 padding-bottom: 8px;
93 margin-top: 24px;
94 }
95 .readme h1:first-child { margin-top: 0; }
96 .readme a { color: var(--link); }
97 .readme code {
98 background: var(--code-bg);
99 padding: 2px 6px;
100 border-radius: 3px;
101 font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace;
102 font-size: 85%;
103 }
104 .readme pre {
105 background: var(--code-bg);
106 padding: 16px;
107 border-radius: 6px;
108 overflow-x: auto;
109 }
110 .readme pre code {
111 padding: 0;
112 background: none;
113 }
114 .readme table {
115 border-collapse: collapse;
116 width: 100%;
117 }
118 .readme th, .readme td {
119 border: 1px solid var(--border);
120 padding: 8px 12px;
121 text-align: left;
122 }
123 .readme th {
124 background: var(--code-bg);
125 }
126 .readme blockquote {
127 border-left: 4px solid var(--border);
128 margin: 0;
129 padding-left: 16px;
130 color: #8b949e;
131 }
132 .readme img {
133 max-width: 100%;
134 }
135 .readme ul, .readme ol {
136 padding-left: 24px;
137 }
138 .nav {
139 margin-bottom: 16px;
140 font-size: 14px;
141 }
142 .nav a {
143 color: var(--link);
144 text-decoration: none;
145 margin-right: 16px;
146 }
147 .nav a:hover {
148 text-decoration: underline;
149 }
150 </style>
151 </head>
152 <body>
153 <div class="header">
154 <h1>
155 <a href="/{{.RepoName}}">{{.RepoName}}</a>
156 {{if .Revision}}<span class="rev">r{{.Revision}}</span>{{end}}
157 </h1>
158 </div>
159 <div class="container">
160 <div class="nav">
161 <a href="/{{.RepoName}}">README</a>
162 <a href="/{{.RepoName}}/tree/{{.Revision}}/">Files</a>
163 <a href="/{{.RepoName}}/log">Log</a>
164 </div>
165 <div class="readme">
166 {{.Content}}
167 </div>
168 </div>
169 </body>
170 </html>`
171
172 var tmpl *template.Template
173
174 func init() {
175 tmpl = template.Must(template.New("page").Parse(pageTemplate))
176 }
177
178 // PageData contains data for rendering a page
179 type PageData struct {
180 Title string
181 RepoName string
182 Revision int64
183 Content template.HTML
184 }
185
186 // RenderMarkdown converts markdown to HTML
187 func RenderMarkdown(markdown []byte) ([]byte, error) {
188 var buf bytes.Buffer
189 if err := md.Convert(markdown, &buf); err != nil {
190 return nil, fmt.Errorf("render markdown: %w", err)
191 }
192 return buf.Bytes(), nil
193 }
194
195 // RenderPage renders a full HTML page
196 func RenderPage(data *PageData) ([]byte, error) {
197 var buf bytes.Buffer
198 if err := tmpl.Execute(&buf, data); err != nil {
199 return nil, fmt.Errorf("render page: %w", err)
200 }
201 return buf.Bytes(), nil
202 }
203
204 // RenderReadme renders README.md as a full HTML page
205 func RenderReadme(repoName string, revision int64, readmeContent []byte) ([]byte, error) {
206 htmlContent, err := RenderMarkdown(readmeContent)
207 if err != nil {
208 return nil, err
209 }
210
211 data := &PageData{
212 Title: repoName,
213 RepoName: repoName,
214 Revision: revision,
215 Content: template.HTML(htmlContent),
216 }
217
218 return RenderPage(data)
219 }
220
221 const treeTemplate = `<!DOCTYPE html>
222 <html lang="en">
223 <head>
224 <meta charset="UTF-8">
225 <meta name="viewport" content="width=device-width, initial-scale=1.0">
226 <title>{{.RepoName}} - Files</title>
227 <style>
228 :root {
229 --bg: #0d1117;
230 --fg: #c9d1d9;
231 --fg-muted: #8b949e;
232 --border: #30363d;
233 --link: #58a6ff;
234 --header-bg: #161b22;
235 --row-hover: #161b22;
236 }
237 * { box-sizing: border-box; }
238 body {
239 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
240 background: var(--bg);
241 color: var(--fg);
242 line-height: 1.5;
243 margin: 0;
244 }
245 .header {
246 background: var(--header-bg);
247 border-bottom: 1px solid var(--border);
248 padding: 16px 24px;
249 }
250 .header h1 { margin: 0; font-size: 20px; }
251 .header h1 a { color: var(--fg); text-decoration: none; }
252 .header .rev { color: var(--fg-muted); font-size: 14px; margin-left: 8px; }
253 .container { max-width: 1012px; margin: 0 auto; padding: 24px; }
254 .nav { margin-bottom: 16px; font-size: 14px; }
255 .nav a { color: var(--link); text-decoration: none; margin-right: 16px; }
256 .nav a:hover { text-decoration: underline; }
257 .nav a.active { color: var(--fg); font-weight: 600; }
258 .breadcrumb { margin-bottom: 16px; font-size: 14px; }
259 .breadcrumb a { color: var(--link); text-decoration: none; }
260 .breadcrumb span { color: var(--fg-muted); margin: 0 4px; }
261 .file-tree {
262 background: var(--header-bg);
263 border: 1px solid var(--border);
264 border-radius: 6px;
265 overflow: hidden;
266 }
267 .file-row {
268 display: flex;
269 align-items: center;
270 padding: 8px 16px;
271 border-bottom: 1px solid var(--border);
272 font-size: 14px;
273 }
274 .file-row:last-child { border-bottom: none; }
275 .file-row:hover { background: var(--row-hover); }
276 .file-icon {
277 width: 20px;
278 margin-right: 8px;
279 text-align: center;
280 color: var(--fg-muted);
281 }
282 .file-name { flex: 1; }
283 .file-name a { color: var(--fg); text-decoration: none; }
284 .file-name a:hover { color: var(--link); text-decoration: underline; }
285 .file-size { color: var(--fg-muted); font-size: 12px; min-width: 80px; text-align: right; }
286 .dir-icon::before { content: "F"; }
287 .file-icon-default::before { content: "D"; }
288 .empty { padding: 32px; text-align: center; color: var(--fg-muted); }
289 </style>
290 </head>
291 <body>
292 <div class="header">
293 <h1>
294 <a href="/{{.RepoName}}">{{.RepoName}}</a>
295 <span class="rev">r{{.Revision}}</span>
296 </h1>
297 </div>
298 <div class="container">
299 <div class="nav">
300 <a href="/{{.RepoName}}">README</a>
301 <a href="/{{.RepoName}}/tree/{{.Revision}}/" class="active">Files</a>
302 <a href="/{{.RepoName}}/log">Log</a>
303 </div>
304 {{if .Path}}
305 <div class="breadcrumb">
306 <a href="/{{.RepoName}}/tree/{{.Revision}}/">root</a>
307 {{range .Breadcrumbs}}<span>/</span><a href="{{.URL}}">{{.Name}}</a>{{end}}
308 </div>
309 {{end}}
310 <div class="file-tree">
311 {{if .Entries}}
312 {{range .Dirs}}
313 <div class="file-row">
314 <span class="file-icon dir-icon"></span>
315 <span class="file-name"><a href="/{{$.RepoName}}/tree/{{$.Revision}}/{{.Path}}">{{.Name}}/</a></span>
316 <span class="file-size">—</span>
317 </div>
318 {{end}}
319 {{range .Files}}
320 <div class="file-row">
321 <span class="file-icon file-icon-default"></span>
322 <span class="file-name"><a href="/{{$.RepoName}}/blob/{{$.Revision}}/{{.Path}}">{{.Name}}</a></span>
323 <span class="file-size">{{.SizeStr}}</span>
324 </div>
325 {{end}}
326 {{else}}
327 <div class="empty">Empty directory</div>
328 {{end}}
329 </div>
330 </div>
331 </body>
332 </html>`
333
334 // TreeEntry for template
335 type TreeEntryView struct {
336 Name string
337 Path string
338 Size int64
339 SizeStr string
340 IsDir bool
341 }
342
343 // Breadcrumb for navigation
344 type Breadcrumb struct {
345 Name string
346 URL string
347 }
348
349 // TreeData for tree template
350 type TreeData struct {
351 RepoName string
352 Revision int64
353 Path string
354 Breadcrumbs []Breadcrumb
355 Entries bool
356 Dirs []TreeEntryView
357 Files []TreeEntryView
358 }
359
360 var treeTmpl *template.Template
361
362 func init() {
363 treeTmpl = template.Must(template.New("tree").Parse(treeTemplate))
364 }
365
366 func formatSize(size int64) string {
367 if size < 1024 {
368 return fmt.Sprintf("%d B", size)
369 } else if size < 1024*1024 {
370 return fmt.Sprintf("%.1f KB", float64(size)/1024)
371 } else if size < 1024*1024*1024 {
372 return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024))
373 }
374 return fmt.Sprintf("%.1f GB", float64(size)/(1024*1024*1024))
375 }
376
377 // RenderTree renders file tree as HTML
378 func RenderTree(data *TreeData) ([]byte, error) {
379 var buf bytes.Buffer
380 if err := treeTmpl.Execute(&buf, data); err != nil {
381 return nil, fmt.Errorf("render tree: %w", err)
382 }
383 return buf.Bytes(), nil
384 }
385
386 const logTemplate = `<!DOCTYPE html>
387 <html lang="en">
388 <head>
389 <meta charset="UTF-8">
390 <meta name="viewport" content="width=device-width, initial-scale=1.0">
391 <title>{{.RepoName}} - Log</title>
392 <style>
393 :root {
394 --bg: #0d1117;
395 --fg: #c9d1d9;
396 --fg-muted: #8b949e;
397 --border: #30363d;
398 --link: #58a6ff;
399 --header-bg: #161b22;
400 --rev-color: #f0883e;
401 --branch-bg: #388bfd26;
402 --branch-color: #58a6ff;
403 }
404 * { box-sizing: border-box; }
405 body {
406 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
407 background: var(--bg);
408 color: var(--fg);
409 line-height: 1.5;
410 margin: 0;
411 }
412 .header {
413 background: var(--header-bg);
414 border-bottom: 1px solid var(--border);
415 padding: 16px 24px;
416 }
417 .header h1 { margin: 0; font-size: 20px; }
418 .header h1 a { color: var(--fg); text-decoration: none; }
419 .container { max-width: 1012px; margin: 0 auto; padding: 24px; }
420 .nav { margin-bottom: 16px; font-size: 14px; }
421 .nav a { color: var(--link); text-decoration: none; margin-right: 16px; }
422 .nav a:hover { text-decoration: underline; }
423 .nav a.active { color: var(--fg); font-weight: 600; }
424 .commits {
425 background: var(--header-bg);
426 border: 1px solid var(--border);
427 border-radius: 6px;
428 overflow: hidden;
429 }
430 .commit {
431 padding: 16px;
432 border-bottom: 1px solid var(--border);
433 }
434 .commit:last-child { border-bottom: none; }
435 .commit-header {
436 display: flex;
437 align-items: center;
438 margin-bottom: 8px;
439 }
440 .commit-rev {
441 font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace;
442 font-weight: 600;
443 color: var(--rev-color);
444 margin-right: 12px;
445 }
446 .commit-rev a { color: inherit; text-decoration: none; }
447 .commit-rev a:hover { text-decoration: underline; }
448 .commit-branch {
449 background: var(--branch-bg);
450 color: var(--branch-color);
451 padding: 2px 8px;
452 border-radius: 12px;
453 font-size: 12px;
454 margin-right: 12px;
455 }
456 .commit-author {
457 color: var(--fg);
458 font-weight: 500;
459 }
460 .commit-date {
461 color: var(--fg-muted);
462 font-size: 12px;
463 margin-left: auto;
464 }
465 .commit-message {
466 color: var(--fg);
467 font-size: 14px;
468 }
469 .empty { padding: 32px; text-align: center; color: var(--fg-muted); }
470 .pagination {
471 margin-top: 16px;
472 text-align: center;
473 }
474 .pagination a {
475 color: var(--link);
476 text-decoration: none;
477 padding: 8px 16px;
478 border: 1px solid var(--border);
479 border-radius: 6px;
480 margin: 0 4px;
481 }
482 .pagination a:hover { background: var(--header-bg); }
483 </style>
484 </head>
485 <body>
486 <div class="header">
487 <h1><a href="/{{.RepoName}}">{{.RepoName}}</a></h1>
488 </div>
489 <div class="container">
490 <div class="nav">
491 <a href="/{{.RepoName}}">README</a>
492 <a href="/{{.RepoName}}/tree/{{.LatestRev}}/">Files</a>
493 <a href="/{{.RepoName}}/log" class="active">Log</a>
494 </div>
495 <div class="commits">
496 {{if .Commits}}
497 {{range .Commits}}
498 <div class="commit">
499 <div class="commit-header">
500 <span class="commit-rev"><a href="/{{$.RepoName}}/tree/{{.Number}}/">r{{.Number}}</a></span>
501 <span class="commit-branch">{{.Branch}}</span>
502 <span class="commit-author">{{.Author}}</span>
503 <span class="commit-date">{{.DateStr}}</span>
504 </div>
505 <div class="commit-message">{{.Message}}</div>
506 </div>
507 {{end}}
508 {{else}}
509 <div class="empty">No commits yet</div>
510 {{end}}
511 </div>
512 {{if or .HasPrev .HasNext}}
513 <div class="pagination">
514 {{if .HasPrev}}<a href="/{{.RepoName}}/log?offset={{.PrevOffset}}"><- Newer</a>{{end}}
515 {{if .HasNext}}<a href="/{{.RepoName}}/log?offset={{.NextOffset}}">Older -></a>{{end}}
516 </div>
517 {{end}}
518 </div>
519 </body>
520 </html>`
521
522 // CommitView for template
523 type CommitView struct {
524 Number int64
525 Branch string
526 Author string
527 Message string
528 DateStr string
529 }
530
531 // LogData for log template
532 type LogData struct {
533 RepoName string
534 LatestRev int64
535 Commits []CommitView
536 HasPrev bool
537 HasNext bool
538 PrevOffset int
539 NextOffset int
540 }
541
542 var logTmpl *template.Template
543
544 func init() {
545 logTmpl = template.Must(template.New("log").Parse(logTemplate))
546 }
547
548 // RenderLog renders commit log as HTML
549 func RenderLog(data *LogData) ([]byte, error) {
550 var buf bytes.Buffer
551 if err := logTmpl.Execute(&buf, data); err != nil {
552 return nil, fmt.Errorf("render log: %w", err)
553 }
554 return buf.Bytes(), nil
555 }
556