package server import ( "bytes" "fmt" "html/template" "path" "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/text" ) /* Markdown to HTML rendering for README.md display. * Uses goldmark with GFM extensions (tables, strikethrough, etc.) * Supports relative link transformation for in-repo navigation. */ var md goldmark.Markdown func init() { md = goldmark.New( goldmark.WithExtensions( extension.GFM, // GitHub Flavored Markdown extension.Typographer, // smart quotes, etc. ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), // auto-generate heading IDs ), goldmark.WithRendererOptions( html.WithHardWraps(), html.WithXHTML(), html.WithUnsafe(), // allow raw HTML in markdown ), ) } /* linkTransformer rewrites relative links to point to repo tree/blob paths. * E.g., ./ide/jetbrains -> /reponame/tree/123/ide/jetbrains */ type linkTransformer struct { repoName string revision int64 currentPath string // current directory in repo (for resolving relative paths) } func (t *linkTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } switch v := n.(type) { case *ast.Link: t.transformLink(v) case *ast.Image: t.transformImage(v) } return ast.WalkContinue, nil }) } func (t *linkTransformer) transformLink(link *ast.Link) { dest := string(link.Destination) /* skip absolute URLs, anchors, and special schemes */ if strings.HasPrefix(dest, "http://") || strings.HasPrefix(dest, "https://") || strings.HasPrefix(dest, "mailto:") || strings.HasPrefix(dest, "#") || strings.HasPrefix(dest, "/") { return } /* resolve relative path */ resolved := t.resolvePath(dest) /* determine if it's likely a directory or file */ /* directories: no extension or ends with / */ isDir := !strings.Contains(path.Base(resolved), ".") || strings.HasSuffix(dest, "/") var newDest string if isDir { newDest = fmt.Sprintf("/%s/tree/%d/%s", t.repoName, t.revision, strings.TrimSuffix(resolved, "/")) } else { newDest = fmt.Sprintf("/%s/blob/%d/%s", t.repoName, t.revision, resolved) } link.Destination = []byte(newDest) } func (t *linkTransformer) transformImage(img *ast.Image) { dest := string(img.Destination) /* skip absolute URLs */ if strings.HasPrefix(dest, "http://") || strings.HasPrefix(dest, "https://") || strings.HasPrefix(dest, "/") { return } /* resolve and point to blob */ resolved := t.resolvePath(dest) newDest := fmt.Sprintf("/%s/blob/%d/%s", t.repoName, t.revision, resolved) img.Destination = []byte(newDest) } func (t *linkTransformer) resolvePath(dest string) string { /* clean up ./ prefix */ dest = strings.TrimPrefix(dest, "./") /* resolve relative to current path */ if t.currentPath != "" { dest = path.Join(t.currentPath, dest) } /* clean up path */ dest = path.Clean(dest) dest = strings.TrimPrefix(dest, "/") return dest } const pageTemplate = ` {{.Title}} - larc

{{.RepoName}} {{if .Revision}}r{{.Revision}}{{end}}

{{.Content}}
` var tmpl *template.Template func init() { tmpl = template.Must(template.New("page").Parse(pageTemplate)) } // PageData contains data for rendering a page type PageData struct { Title string RepoName string Revision int64 Content template.HTML } // RenderMarkdown converts markdown to HTML func RenderMarkdown(markdown []byte) ([]byte, error) { var buf bytes.Buffer if err := md.Convert(markdown, &buf); err != nil { return nil, fmt.Errorf("render markdown: %w", err) } return buf.Bytes(), nil } // RenderMarkdownWithLinks converts markdown to HTML with relative link transformation. // Transforms links like ./path/to/dir into /repo/tree/rev/path/to/dir func RenderMarkdownWithLinks(markdown []byte, repoName string, revision int64, currentPath string) ([]byte, error) { transformer := &linkTransformer{ repoName: repoName, revision: revision, currentPath: currentPath, } mdWithLinks := goldmark.New( goldmark.WithExtensions( extension.GFM, extension.Typographer, ), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), goldmark.WithRendererOptions( html.WithHardWraps(), html.WithXHTML(), html.WithUnsafe(), ), ) /* parse first, transform, then render */ reader := text.NewReader(markdown) doc := mdWithLinks.Parser().Parse(reader) /* apply link transformation */ transformer.Transform(doc.(*ast.Document), reader, nil) /* render to HTML */ var buf bytes.Buffer if err := mdWithLinks.Renderer().Render(&buf, markdown, doc); err != nil { return nil, fmt.Errorf("render markdown: %w", err) } return buf.Bytes(), nil } // RenderPage renders a full HTML page func RenderPage(data *PageData) ([]byte, error) { var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("render page: %w", err) } return buf.Bytes(), nil } // RenderReadme renders README.md as a full HTML page. // Transforms relative links to point to repo tree/blob paths. func RenderReadme(repoName string, revision int64, readmeContent []byte) ([]byte, error) { /* use link transformer to rewrite relative paths */ htmlContent, err := RenderMarkdownWithLinks(readmeContent, repoName, revision, "") if err != nil { return nil, err } data := &PageData{ Title: repoName, RepoName: repoName, Revision: revision, Content: template.HTML(htmlContent), } return RenderPage(data) } const treeTemplate = ` {{.RepoName}} - Files

{{.RepoName}} r{{.Revision}}

{{if .Path}} {{end}}
{{if .Entries}} {{range .Dirs}} {{end}} {{range .Files}}
{{.Name}} {{.SizeStr}}
{{end}} {{else}}
Empty directory
{{end}}
` // TreeEntry for template type TreeEntryView struct { Name string Path string Size int64 SizeStr string IsDir bool } // Breadcrumb for navigation type Breadcrumb struct { Name string URL string } // TreeData for tree template type TreeData struct { RepoName string Revision int64 Path string Breadcrumbs []Breadcrumb Entries bool Dirs []TreeEntryView Files []TreeEntryView } var treeTmpl *template.Template func init() { treeTmpl = template.Must(template.New("tree").Parse(treeTemplate)) } func formatSize(size int64) string { if size < 1024 { return fmt.Sprintf("%d B", size) } else if size < 1024*1024 { return fmt.Sprintf("%.1f KB", float64(size)/1024) } else if size < 1024*1024*1024 { return fmt.Sprintf("%.1f MB", float64(size)/(1024*1024)) } return fmt.Sprintf("%.1f GB", float64(size)/(1024*1024*1024)) } // RenderTree renders file tree as HTML func RenderTree(data *TreeData) ([]byte, error) { var buf bytes.Buffer if err := treeTmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("render tree: %w", err) } return buf.Bytes(), nil } const logTemplate = ` {{.RepoName}} - Log

{{.RepoName}}

{{if .Commits}} {{range .Commits}}
r{{.Number}} {{.Branch}} {{.Author}} {{.DateStr}}
{{.Message}}
{{end}} {{else}}
No commits yet
{{end}}
{{if or .HasPrev .HasNext}} {{end}}
` // CommitView for template type CommitView struct { Number int64 Branch string Author string Message string DateStr string } // LogData for log template type LogData struct { RepoName string LatestRev int64 Commits []CommitView HasPrev bool HasNext bool PrevOffset int NextOffset int } var logTmpl *template.Template func init() { logTmpl = template.Must(template.New("log").Parse(logTemplate)) } // RenderLog renders commit log as HTML func RenderLog(data *LogData) ([]byte, error) { var buf bytes.Buffer if err := logTmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("render log: %w", err) } return buf.Bytes(), nil } /* Home page templates for server index */ const homePageTemplate = ` larc

larc

{{.Content}}
{{if .Repos}}

Repositories

{{range .Repos}}
{{.Name}} {{if .Description}}{{.Description}}{{end}} {{if .Public}}public{{end}}
{{end}}
{{end}}
` const repoListTemplate = ` larc

larc

Repositories

{{if .Repos}} {{range .Repos}}
{{.Name}} {{if .Description}}{{.Description}}{{end}} {{if .Public}}public{{end}}
{{end}} {{else}}
No repositories configured
{{end}}
` // HomePageData for home page template type HomePageData struct { Content template.HTML Repos []RepoConfig } // RepoListData for repo list template type RepoListData struct { Repos []RepoConfig } var homePageTmpl *template.Template var repoListTmpl *template.Template func init() { homePageTmpl = template.Must(template.New("homepage").Parse(homePageTemplate)) repoListTmpl = template.Must(template.New("repolist").Parse(repoListTemplate)) } // RenderHomePage renders home page with custom README.md func RenderHomePage(readmeContent []byte, repos []RepoConfig) ([]byte, error) { htmlContent, err := RenderMarkdown(readmeContent) if err != nil { return nil, err } data := &HomePageData{ Content: template.HTML(htmlContent), Repos: repos, } var buf bytes.Buffer if err := homePageTmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("render home page: %w", err) } return buf.Bytes(), nil } // RenderRepoList renders repository list page func RenderRepoList(repos []RepoConfig) ([]byte, error) { data := &RepoListData{ Repos: repos, } var buf bytes.Buffer if err := repoListTmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("render repo list: %w", err) } return buf.Bytes(), nil } /* Blob view template with syntax highlighting and line anchors. * Uses highlight.js for syntax highlighting. * Line numbers are clickable and update URL hash (e.g. #L32). */ const blobTemplate = ` {{.FileName}} - {{.RepoName}}

{{.RepoName}} r{{.Revision}}

{{if .Breadcrumbs}} {{end}}
{{.LineCount}} lines · {{.SizeStr}} Raw
{{range .Lines}} {{end}}
{{.Num}} {{.Content}}
` // BlobLine represents a line of code type BlobLine struct { Num int Content string } // BlobData for blob view template type BlobData struct { RepoName string Revision int64 FilePath string FileName string Breadcrumbs []Breadcrumb Lines []BlobLine LineCount int SizeStr string Language string } var blobTmpl *template.Template func init() { blobTmpl = template.Must(template.New("blob").Parse(blobTemplate)) } // RenderBlob renders file content with syntax highlighting func RenderBlob(data *BlobData) ([]byte, error) { var buf bytes.Buffer if err := blobTmpl.Execute(&buf, data); err != nil { return nil, fmt.Errorf("render blob: %w", err) } return buf.Bytes(), nil }