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
`
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
{{if .Path}}
{{end}}
{{if .Entries}}
{{range .Dirs}}
{{end}}
{{range .Files}}
{{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
{{if .Commits}}
{{range .Commits}}
{{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
{{.Content}}
{{if .Repos}}
Repositories
{{range .Repos}}
{{.Name}}
{{if .Description}}
{{.Description}}{{end}}
{{if .Public}}
public{{end}}
{{end}}
{{end}}
`
const repoListTemplate = `
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
}