Download
173 lines 4.9 KiBGoModified 2026-10-27 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
package render

import (
	"fmt"
	"html/template"
	"path"
	"strings"
	"time"

	"code.bruce-hill.com/repo-renderer/internal/gitx"
	"code.bruce-hill.com/repo-renderer/internal/highlight"
	"code.bruce-hill.com/repo-renderer/internal/markdown"
)

// ---- file pages ----

type fileView struct {
	Kind     string // code, markdown, image, binary
	Name     string
	Size     string
	Modified string
	Lines    int // as wc -l counts, the same as the listings
	Lexer    string
	Exec     bool
	Notice   string
	RawURL   string
	ImageURL string // for Kind image: the URL to show (themed copy for SVG)
	Code     template.HTML
	LineNums template.HTML
	Rendered template.HTML
}

var imageExts = map[string]bool{
	".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".webp": true, ".avif": true, ".bmp": true, ".ico": true,
	".svg": true,
}

func isMarkdown(name string) bool {
	switch strings.ToLower(path.Ext(name)) {
	case ".md", ".markdown", ".mdown", ".mkd":
		return true
	}
	return false
}

func lineNumbers(n int) template.HTML {
	var b strings.Builder
	b.Grow(n * 32)
	// Anchors are display:block, so no newlines between them: inside a
	// <pre> those would add a second line per number.
	for i := 1; i <= n; i++ {
		fmt.Fprintf(&b, `<a id="L%d" href="#L%d">%d</a>`, i, i, i)
	}
	return template.HTML(b.String())
}

func (c *repoCtx) renderBlob(e *gitx.Entry) error {
	cfg := c.s.Cfg
	name := path.Base(e.Path)
	themed := c.s.Lay.SVG(c.key, e.Path)
	fv := &fileView{Name: name, Size: humanSize(e.Size), Exec: e.IsExecutable()}
	if t := c.mtime[e.Path]; t > 0 {
		fv.Modified = time.Unix(t, 0).UTC().Format("2006-01-02")
	}
	// The count the listings show, as wc -l counts; binary files have none.
	if n := c.lines[e.Path]; n > 0 {
		fv.Lines = n
	}

	// Too big to serve: decide from the size ls-tree reported and never
	// read the blob, so a pusher cannot make every worker hold a huge
	// file in memory at once.
	if e.Size > cfg.RawMaxBytes {
		if err := removeOutputs(themed); err != nil {
			return err
		}
		if err := c.removeRaw(e.Path); err != nil {
			return err
		}
		fv.Kind = "large"
		fv.Notice = "This file is too large to serve directly; clone the repository to get it."
		return c.writeFilePage(e, fv)
	}

	data, err := c.batch.Get(e.SHA)
	if err != nil {
		return err
	}
	binary := highlight.IsBinary(data)
	ext := strings.ToLower(path.Ext(name))
	// Raw copies get no gzip twin: raw text is rarely fetched, and a
	// committed "x.gz" would otherwise collide with x's.
	if err := writeFileAtomic(c.s.Lay.Raw(c.key, e.Path), data); err != nil {
		return err
	}
	fv.RawURL = c.s.RawURL(c.key, e.Path)
	// An SVG also gets a themed copy for embedding (see themeSVG).
	if ext == ".svg" {
		if err := writePair(themed, themeSVG(data, svgRootEnd(data), c.s.HL.TextColour(), c.s.HL.LightTextColour()), true); err != nil {
			return err
		}
	}
	switch {
	case imageExts[ext] && (binary || ext == ".svg"):
		fv.Kind = "image"
		fv.ImageURL = fv.RawURL
		if ext == ".svg" {
			fv.ImageURL = c.s.SVGURL(c.key, e.Path)
		}
	case binary:
		fv.Kind = "binary"
	case e.Size > cfg.HighlightMaxBytes:
		// Text, but embedding megabytes of it in a page helps nobody and
		// costs the renderer several times the file size in memory.
		fv.Kind = "large"
		fv.Notice = "This file is too large to display here; use the raw link."
	case isMarkdown(name) && e.Size <= cfg.MarkdownMaxBytes:
		fv.Kind = "markdown"
		rendered, err := c.s.MD.Render(data, c.mdContext(path.Dir(e.Path)))
		if err != nil {
			return err
		}
		fv.Rendered = rendered
		lx := c.s.HL.ByName("markdown")
		fv.Lexer = displayLang(highlight.Name(lx))
		fv.Code = c.s.HL.HTML(data, lx)
		fv.LineNums = lineNumbers(highlight.LineCount(data))
	default:
		fv.Kind = "code"
		lx := c.s.HL.ByFile(e.Path, data)
		fv.Lexer = displayLang(highlight.Name(lx))
		fv.Code = c.s.HL.HTML(data, lx)
		fv.LineNums = lineNumbers(highlight.LineCount(data))
	}
	return c.writeFilePage(e, fv)
}

// writeFilePage renders and writes the HTML page for one blob.
func (c *repoCtx) writeFilePage(e *gitx.Entry, fv *fileView) error {
	page := c.page()
	page.Title = c.repo + c.branchSuffix() + "/" + e.Path
	page.URL = c.s.absURL(c.s.CodeURL(c.key, e.Path))
	page.BodyClass = "file file-" + fv.Kind
	page.Crumbs = c.s.crumbsFor(c.key, e.Path)
	page.Drawer = c.drawer(e.Path)
	page.File = fv
	html, err := c.s.render("file", page)
	if err != nil {
		return err
	}
	return writePair(c.s.Lay.Page(c.key, e.Path), html, true)
}

func (c *repoCtx) mdContext(dir string) markdown.Context {
	if dir == "." {
		dir = ""
	}
	repo := c.key
	return markdown.Context{
		Dir:      dir,
		PageURL:  func(p string) string { return c.s.CodeURL(repo, p) },
		ImageURL: func(p string) string { return c.s.imageURL(repo, p) },
	}
}

// imageURL picks the URL to embed for an image path: SVGs come from the
// themed copy under /svg, everything else from /raw.
func (s *Site) imageURL(repo, p string) string {
	if strings.EqualFold(path.Ext(p), ".svg") {
		return s.SVGURL(repo, p)
	}
	return s.RawURL(repo, p)
}