internal/renderer/renderer.go

package renderer

import (
	"bytes"
	"fmt"
	"html/template"
	"io"
	"io/fs"
	"os"
	"path"
	"path/filepath"
	"static-repo/internal/utils"
	"strings"
	"sync"

	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
	"github.com/alecthomas/chroma/v2/lexers"
	"github.com/alecthomas/chroma/v2/styles"
	"github.com/yuin/goldmark"
	highlighting "github.com/yuin/goldmark-highlighting/v2"
	meta "github.com/yuin/goldmark-meta"
	"github.com/yuin/goldmark/extension"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer/html"
	alertcallouts "github.com/zmtcreative/gm-alert-callouts"
)

type Renderer struct {
	Markdown  goldmark.Markdown
	templates *template.Template
	bufPool   sync.Pool
}

func NewRenderer(templatesFS fs.FS) (*Renderer, error) {
	md := goldmark.New(
		goldmark.WithExtensions(
			highlighting.NewHighlighting(
				highlighting.WithFormatOptions(
					chromahtml.WithClasses(true),
					chromahtml.TabWidth(4),
				),
			),
			alertcallouts.NewAlertCallouts(alertcallouts.UseGFMStrictIcons()),
			extension.GFM,
			meta.Meta,
			NewLinkExtension(),
		),
		goldmark.WithRendererOptions(
			html.WithUnsafe(),
		),
	)

	tmpl := template.New("renderer")
	_, err := tmpl.ParseFS(templatesFS,
		"templates/partials/markdown.html",
		"templates/partials/code.html",
		"templates/partials/pdf.html",
		"templates/partials/download.html",
	)
	if err != nil {
		return nil, fmt.Errorf("failed to parse renderer templates: %v", err)
	}

	return &Renderer{
		Markdown:  md,
		templates: tmpl,
		bufPool: sync.Pool{
			New: func() interface{} {
				return new(bytes.Buffer)
			},
		},
	}, nil
}

func (r *Renderer) RenderFile(filePath, contentDir, outputDir, urlPrefix string) (string, error) {
	ctype := utils.GetContentType(filePath)

	fmt.Printf("Rendering %s: %s\n", ctype, filePath)

	if ctype == "text" || ctype == "markdown" {
		info, err := os.Stat(filePath)
		if err == nil && info.Size() > 1024*1024 {
			return r.RenderDownload(filePath, contentDir, outputDir, urlPrefix, "This file is too large to render.")
		}
	}

	if ctype == "markdown" {
		return r.RenderMarkdown(filePath)
	}

	if ctype == "image" {
		return r.RenderMedia(filePath, "img", contentDir, outputDir, urlPrefix)
	}

	if ctype == "video" {
		return r.RenderMedia(filePath, "video", contentDir, outputDir, urlPrefix)
	}

	if ctype == "pdf" {
		return r.RenderPDF(filePath, contentDir, outputDir, urlPrefix)
	}

	if ctype == "text" {
		return r.HighlightCode(filePath)
	}

	if ctype == "binary" || ctype == "archive" {
		return r.RenderDownload(filePath, contentDir, outputDir, urlPrefix, "")
	}

	return "", fmt.Errorf("unsupported file type: %s", ctype)
}

func (r *Renderer) RenderPDF(filePath, contentDir, outputDir, urlPrefix string) (string, error) {
	relPath, err := filepath.Rel(contentDir, filePath)
	if err != nil {
		return "", fmt.Errorf("failed to get relative path for %s: %v", filePath, err)
	}
	destPath := filepath.Join(outputDir, relPath)
	if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
		return "", err
	}
	if err := utils.CopyFile(filePath, destPath); err != nil {
		return "", err
	}

	buf := r.bufPool.Get().(*bytes.Buffer)
	buf.Reset()
	defer r.bufPool.Put(buf)

	// Use path.Join for URLs to ensure forward slashes
	urlPath := path.Join(urlPrefix, filepath.ToSlash(relPath))
	if !strings.HasPrefix(urlPath, "/") {
		urlPath = "/" + urlPath
	}

	data := struct {
		Path string
	}{
		Path: urlPath,
	}
	if err := r.templates.ExecuteTemplate(buf, "pdf.html", data); err != nil {
		return "", err
	}
	return buf.String(), nil
}

func (r *Renderer) RenderDownload(filePath, contentDir, outputDir, urlPrefix, message string) (string, error) {
	relPath, err := filepath.Rel(contentDir, filePath)
	if err != nil {
		return "", fmt.Errorf("failed to get relative path for %s: %v", filePath, err)
	}
	destPath := filepath.Join(outputDir, relPath)
	if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
		return "", err
	}
	if err := utils.CopyFile(filePath, destPath); err != nil {
		return "", err
	}

	// Use path.Join for URLs to ensure forward slashes
	urlPath := path.Join(urlPrefix, filepath.ToSlash(relPath))
	if !strings.HasPrefix(urlPath, "/") {
		urlPath = "/" + urlPath
	}

	buf := r.bufPool.Get().(*bytes.Buffer)
	buf.Reset()
	defer r.bufPool.Put(buf)

	data := struct {
		Path    string
		Name    string
		Message string
	}{
		Path:    urlPath,
		Name:    filepath.Base(filePath),
		Message: message,
	}
	if err := r.templates.ExecuteTemplate(buf, "download.html", data); err != nil {
		return "", err
	}

	return buf.String(), nil
}

// RenderMarkdown renders a Markdown file and returns the HTML content
func (r *Renderer) RenderMarkdown(filePath string) (string, error) {
	input, err := os.ReadFile(filePath)
	if err != nil {
		return "", err
	}

	contentBuf := r.bufPool.Get().(*bytes.Buffer)
	contentBuf.Reset()
	defer r.bufPool.Put(contentBuf)

	context := parser.NewContext()
	if err := r.Markdown.Convert(input, contentBuf, parser.WithContext(context)); err != nil {
		return "", err
	}

	outBuf := r.bufPool.Get().(*bytes.Buffer)
	outBuf.Reset()
	defer r.bufPool.Put(outBuf)

	data := struct {
		Content template.HTML
	}{
		Content: template.HTML(contentBuf.String()),
	}
	if err := r.templates.ExecuteTemplate(outBuf, "markdown.html", data); err != nil {
		return "", err
	}

	return outBuf.String(), nil
}

func (r *Renderer) ExtractFrontmatter(filePath string) (map[string]interface{}, error) {
	input, err := os.ReadFile(filePath)
	if err != nil {
		return nil, err
	}

	context := parser.NewContext()
	// We only need to parse, not render, but goldmark doesn't have a simple "Parse" that populates meta
	// without some extra work. Using Convert with io.Discard is recommended for side effects.
	if err := r.Markdown.Convert(input, io.Discard, parser.WithContext(context)); err != nil {
		return nil, err
	}

	return meta.Get(context), nil
}

func (r *Renderer) HighlightCode(filePath string) (string, error) {
	content, err := os.ReadFile(filePath)
	if err != nil {
		return "", err
	}

	lexer := lexers.Get(filePath)
	if lexer == nil {
		lexer = lexers.Analyse(string(content))
	}
	if lexer == nil {
		lexer = lexers.Fallback
	}

	style := styles.Get("monokai")
	formatter := chromahtml.New(chromahtml.WithClasses(true))

	iterator, err := lexer.Tokenise(nil, string(content))
	if err != nil {
		return "", err
	}

	buf := r.bufPool.Get().(*bytes.Buffer)
	buf.Reset()
	defer r.bufPool.Put(buf)

	err = formatter.Format(buf, style, iterator)
	if err != nil {
		return "", err
	}

	outBuf := r.bufPool.Get().(*bytes.Buffer)
	outBuf.Reset()
	defer r.bufPool.Put(outBuf)

	data := struct {
		Content template.HTML
	}{
		Content: template.HTML(buf.String()),
	}
	if err := r.templates.ExecuteTemplate(outBuf, "code.html", data); err != nil {
		return "", err
	}
	return outBuf.String(), nil
}

func (r *Renderer) RenderMedia(filePath, tag, contentDir, outputDir, urlPrefix string) (string, error) {
	relPath, err := filepath.Rel(contentDir, filePath)
	if err != nil {
		return "", fmt.Errorf("failed to get relative path for %s: %v", filePath, err)
	}

	// Copy media file to output dir
	destPath := filepath.Join(outputDir, relPath)
	if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
		return "", err
	}
	if err := utils.CopyFile(filePath, destPath); err != nil {
		return "", err
	}

	// Use path.Join for URLs to ensure forward slashes
	urlPath := path.Join(urlPrefix, filepath.ToSlash(relPath))
	if !strings.HasPrefix(urlPath, "/") {
		urlPath = "/" + urlPath
	}

	if tag == "img" {
		return fmt.Sprintf(`<img src="%s" alt="%s">`, urlPath, filepath.Base(filePath)), nil
	}
	return fmt.Sprintf(`<video controls src="%s"></video>`, urlPath), nil
}