123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275 | package render
import (
"html/template"
"path"
"path/filepath"
"slices"
"strings"
"time"
"code.bruce-hill.com/repo-renderer/internal/gitx"
"code.bruce-hill.com/repo-renderer/internal/highlight"
)
// ---- listings ----
type listingView struct {
IsRoot bool
Entries []entryView
More int // entries beyond the fold (marked Extra), 0 when none
Readme *readmeView
}
type entryView struct {
Name string
URL string
Kind string // dir, file, symlink, submodule
Exec bool
Size string // a type page's size, in its row's unit
Bars *sizeBars // a listing's Lines and Size, each with a bar
Modified string // date of the last commit touching it
ModifiedISO string
ModifiedFull string // date and time, for the tooltip
Target string
Dirs []crumb // folders leading to it, each linked, when listed by full path
Note string // shown after the name, like a symlink's target
Extra bool // hidden behind the "N more…" row
}
type readmeView struct {
Name string
URL string
HTML template.HTML
Pre bool
Text string
}
// readme precedence, compared case-insensitively; "readme.*" is the catch-all.
var readmeOrder = []string{"readme.md", "readme.markdown", "readme", "readme.txt"}
func pickReadme(n *node) *node {
lower := map[string]*node{}
for name, ch := range n.children {
if ch.dir || ch.entry.IsSymlink() || ch.entry.IsSubmodule() {
continue
}
l := strings.ToLower(name)
if _, dup := lower[l]; !dup || name < lower[l].name {
lower[l] = ch
}
}
for _, want := range readmeOrder {
if ch, ok := lower[want]; ok {
return ch
}
}
var first string
for l := range lower {
if strings.HasPrefix(l, "readme.") && (first == "" || l < first) {
first = l
}
}
return lower[first]
}
// fullTime is a commit time in a tooltip, until script (if any) replaces
// it with the reader's local time.
const fullTime = "2006-01-02 15:04 UTC"
// setModified fills in the Modified column from a commit time, if known.
func (ev *entryView) setModified(t int64) {
if t > 0 {
tm := time.Unix(t, 0).UTC()
ev.Modified, ev.ModifiedISO, ev.ModifiedFull = tm.Format("2006-01-02"), tm.Format(time.RFC3339), tm.Format(fullTime)
}
}
// lineCount formats a line count for the size column: "1 line", "4.4K lines".
func lineCount(n int) string {
if n == 1 {
return "1 line"
}
return humanCount(n) + " lines"
}
// maxSymlinkTarget bounds how much of a symlink blob is read for display;
// anything longer than a path can be is not worth a page's while.
const maxSymlinkTarget = 4096
// maxSymlinkHops bounds how many symlinks resolving one may pass through,
// as the kernel's ELOOP does, so a cycle ends.
const maxSymlinkHops = 40
// symlinkTarget returns the path a symlink entry points to, for display.
func (c *repoCtx) symlinkTarget(e *gitx.Entry) (string, error) {
if e.Size > maxSymlinkTarget {
return "(target too long)", nil
}
b, err := c.batch.Get(e.SHA)
if err != nil {
return "", err
}
return string(b), nil
}
// symlinkURL returns the page of what the symlink at link resolves to,
// following further symlinks along the way, or "" when it leaves the
// repository, dangles, loops, or ends at something without a page.
func (c *repoCtx) symlinkURL(link string) (string, error) {
var done []string // resolved components, free of symlinks
if d := path.Dir(link); d != "." {
done = strings.Split(d, "/")
}
rest := []string{path.Base(link)}
for hops := 0; len(rest) > 0; {
comp := rest[0]
rest = rest[1:]
switch comp {
case "", ".":
continue
case "..":
if len(done) == 0 {
return "", nil // above the repository root
}
done = done[:len(done)-1]
continue
}
cand := strings.Join(append(slices.Clip(done), comp), "/")
e := c.tree.byPath[cand]
if e == nil || !e.IsSymlink() {
done = append(done, comp)
continue
}
if hops++; hops > maxSymlinkHops || e.Size > maxSymlinkTarget {
return "", nil
}
b, err := c.batch.Get(e.SHA)
if err != nil {
return "", err
}
target := string(b)
if strings.HasPrefix(target, "/") {
return "", nil // absolute: somewhere on the author's machine
}
rest = append(strings.Split(target, "/"), rest...)
}
p := strings.Join(done, "/")
if c.tree.dir(p) != nil {
return c.s.CodeURL(c.key, p), nil
}
if e := c.tree.byPath[p]; e != nil && !e.IsSubmodule() {
return c.s.CodeURL(c.key, p), nil
}
return "", nil
}
func (c *repoCtx) renderListing(dir string) error {
lay := c.s.Lay
n := c.tree.dir(dir)
if n == nil {
if c.tree.byPath[dir] != nil {
// The directory became a file of the same name; its page is
// the file page a worker has just written.
return nil
}
// Directory vanished: drop its page.
page := lay.Page(c.key, dir)
if err := removeOutputs(page); err != nil {
return err
}
pruneEmptyDirs(filepath.Dir(page), lay.RepoDir(c.key))
return nil
}
var entries []entryView
for _, ch := range sortedChildren(n) {
name := ch.name
full := name
if dir != "" {
full = dir + "/" + name
}
ev := entryView{Name: name}
ev.setModified(c.mtime[full])
switch {
case ch.dir:
ev.Name, ev.URL, ev.Kind = name+"/", c.s.CodeURL(c.key, full), "dir"
case ch.entry.IsSymlink():
target, err := c.symlinkTarget(ch.entry)
if err != nil {
return err
}
url, err := c.symlinkURL(full)
if err != nil {
return err
}
ev.URL, ev.Kind, ev.Target = url, "symlink", target
case ch.entry.IsSubmodule():
ev.Kind, ev.Target = "submodule", ch.entry.SHA[:7]
default:
ev.URL, ev.Kind, ev.Exec = c.s.CodeURL(c.key, full), "file", ch.entry.IsExecutable()
}
ev.Bars = c.sizeBars(dir, full, ev.URL)
entries = append(entries, ev)
}
lv := &listingView{IsRoot: dir == "", Entries: entries}
if rm := pickReadme(n); rm != nil {
rv, err := c.readmeView(dir, rm)
if err != nil {
return err
}
lv.Readme = rv
}
// With a README below, the listing is a short table of contents and the
// rest folds away; without one, the listing is the page, so show it all.
if fold := c.s.Cfg.ListingTruncate; lv.Readme != nil && len(entries) > fold {
for i := fold; i < len(entries); i++ {
entries[i].Extra = true
}
lv.More = len(entries) - fold
}
page := c.page()
page.BodyClass = "listing"
page.Crumbs = c.s.crumbsFor(c.key, dir)
page.Drawer = c.drawer(dir)
page.Listing = lv
if lv.IsRoot {
page.Title = c.repo + c.branchSuffix()
// Only the root shows lines of code: every push rewrites it, so its
// counts stay current, where other pages would keep stale ones.
page.Stats = c.stats
} else {
page.Title = c.repo + c.branchSuffix() + "/" + dir + "/"
}
page.URL = c.s.absURL(c.s.CodeURL(c.key, dir))
html, err := c.s.render("listing", page)
if err != nil {
return err
}
return writePair(lay.Page(c.key, dir), html, true)
}
func (c *repoCtx) readmeView(dir string, rm *node) (*readmeView, error) {
e := rm.entry
if e.Size > c.s.Cfg.MarkdownMaxBytes {
return nil, nil
}
data, err := c.batch.Get(e.SHA)
if err != nil {
return nil, err
}
if highlight.IsBinary(data) {
return nil, nil
}
rv := &readmeView{Name: rm.name, URL: c.s.CodeURL(c.key, e.Path)}
if isMarkdown(rm.name) {
html, err := c.s.MD.Render(data, c.mdContext(dir))
if err != nil {
return nil, err
}
rv.HTML = html
} else {
rv.Pre = true
rv.Text = string(data)
}
return rv, nil
}
|