// page.go — what a template gets, and how links are formed. // // The same templates render two ways: as a static export (relative links, // ".html" suffixes, no server-side features) and as the live web app // (absolute routes, htmx-driven filters, comments and jj queries). Pages carry // Live so a template can ask for the difference in one place. package web import ( "html/template" "net/url" "strconv" "strings" ) type page struct { Site *Site Title string Kind string // index | file | files | notes | note | jj | change | session Root string // static: relative prefix; live: route prefix ending in "/" Live bool Rev string // revision being browsed ("" = the change the log points at) ChangeID string // resolved change id for Rev — what pages name and link Commit string // the commit under it — what content is read and cached at Default string // the change the log points at Path string // this request's path, so the change bar can re-aim it Current string // current file path, for the tree User string // who web comments are attributed to // what this view is scoped to. Scope is "change" (only what was recorded // at ChangeID) or "all"; ScopeEmpty means "change" was asked for but // nothing was ever recorded there, so the whole log is showing instead. Scope string ScopeEmpty bool Here int // notes recorded at this change Elsewhere int // notes recorded at any other change ChangeInfo *Row Tree template.HTML File *File Files []*File // every file at this change, for counts // one note on its own page — the permalink. Excerpt is the code it is // about, already rendered; ExErr says why there is none. Note *Note Thread *ThreadNode Excerpt template.HTML ExFrom, ExTo int ExErr string Notes []*Note Filter NoteFilter Facets *Facets Sessions []*Session Compact bool Window string JJ *JJView Change *Change Conv *Conv Err string } // JJView is one rendered jj query: what was asked, and what came back. type JJView struct { Root string Kind string // log | op Revset string AtOp string Limit int Rows []Row Err string } // params carries the two things every link must not lose: which change you // are looking at, and how wide the view is. func (p *page) params() string { return p.paramsWith(p.Scope) } func (p *page) paramsWith(scope string) string { var q []string // "@" is a revset like any other now that the default change is the log's, // so dropping it here would slide the reader off the change they picked if p.Rev != "" { q = append(q, "rev="+url.QueryEscape(p.Rev)) } if scope == "all" { q = append(q, "scope=all") } if len(q) == 0 { return "" } return "?" + strings.Join(q, "&") } // ScopeHref is this same page seen with the other scope. func (p *page) ScopeHref(scope string) string { path := p.Path if path == "" { path = p.liveRoot() } return path + p.paramsWith(scope) } // BackHref is this page again at the change the log points at. func (p *page) BackHref() string { q := "" if p.Scope == "all" { q = "?scope=all" } return p.JumpPath() + q } // JumpPath is where the change picker submits: your own page when it is one // that can be read at any change, the file tree otherwise. func (p *page) JumpPath() string { switch p.Kind { case "index", "file", "files", "notes", "note": if p.Path != "" { return p.Path } } return p.liveRoot() + "files" } func (p *page) liveRoot() string { root := p.Root if root == "" { return "/" } if !strings.HasPrefix(root, "/") { root = "/" + root } if !strings.HasSuffix(root, "/") { root += "/" } return root } // Href maps a logical destination to a URL for this page's mode. Kinds the // static export has no page for return "", and templates guard on .Live. func (p *page) Href(kind string, arg ...string) string { a := "" if len(arg) > 0 { a = arg[0] } if p.Live { root := p.liveRoot() switch kind { case "": return root + p.params() case "notes": return root + "notes" + p.params() case "notes-list": return root + "notes/list" + p.params() case "files": return root + "files" + p.params() case "tree": return root + "tree" + p.params() case "jj": return root + "jj" case "jj-log": return root + "jj/log" case "jj-op": return root + "jj/op" case "activity": return root + "activity" case "code": return root + "code/" + escPath(a) + p.params() case "note": return root + "note/" + a case "change": return root + "change/" + a case "session": return root + "session/" + escPath(a) case "asset": return root + a } return root } switch kind { case "": return p.Root + "index.html" case "notes": return p.Root + "notes.html" case "files": return p.Root + "files.html" case "tree": return p.Root + "tree.html" case "code": return p.Root + "code/" + a + ".html" case "note": return p.Root + "note/" + a + ".html" case "asset": return p.Root + a } return "" } // codeLink is the tree's per-file linker for this page's mode. func (p *page) codeLink() func(string) string { return func(path string) string { return p.Href("code", path) } } func escPath(p string) string { parts := strings.Split(p, "/") for i, s := range parts { parts[i] = url.PathEscape(s) } return strings.Join(parts, "/") } // ConvHref opens the conversation a note was written in, anchored on it. It // needs the transcripts on this machine, so the static export has none — and // the conversation itself does not link back to where you already are. func (p *page) ConvHref(n *Note) string { if !p.Live || p.Kind == "session" || n.Session == "" || safeSession(n.Session) != nil { return "" } id := strconv.Itoa(n.ID) return p.Href("session", n.Session) + "?note=" + id + "#note-" + id } // PermaHref is the note's own page: the link to hand someone else. It is the // one link on the card that names nothing but the note, so it survives the // change scope, the filters, and the file having moved on since. // // It is empty on the note's own page, which is where it already points, and in // a static export of a different change, which has no page for it. func (p *page) PermaHref(n *Note) string { if p.Kind == "note" && (p.Note == nil || p.Note.ID == n.ID) { return "" } if !p.Live && p.Site != nil && p.Site.NoteByID(n.ID) == nil { return "" } return p.Href("note", strconv.Itoa(n.ID)) } // ThreadHref opens the incoming replies and mentions for a note. The focused // root only needs an in-page jump; every other card gets its own permalink. func (p *page) ThreadHref(n *Note) string { if len(n.Replies) == 0 && len(n.Mentions) == 0 { return "" } if p.Kind == "note" && p.Note != nil && p.Note.ID == n.ID { return "#discussion" } h := p.Href("note", strconv.Itoa(n.ID)) if h == "" { return "" } return h + "#discussion" } // ReplyHref is the explicit parent link. A static export may not contain the // parent's change, in which case it leaves the relationship as text instead of // manufacturing a link to a page the export did not write. func (p *page) ReplyHref(n *Note) string { if n.ReplyTo < 1 { return "" } if !p.Live && p.Site != nil && p.Site.NoteByID(n.ReplyTo) == nil { return "" } return p.Href("note", strconv.Itoa(n.ReplyTo)) } // HasCode reports whether this view has a page for a path. The live app can // render any file at the change it is reading; a static export only wrote the // ones present at the change it holds, so a note about a file that is not // there must not offer a link into nothing. func (p *page) HasCode(path string) bool { if p.Live { return path != "" } for _, f := range p.Files { if f.Path == path { return true } } return false } // NoteHref points at the noted line, or the note list when it is unpinned. // The ?note= is what turns a jump into an arrival: the page opens in notes // focus with the card flashed and its lines marked (see app.js). func (p *page) NoteHref(n *Note) string { if n.File == "" { return p.Href("notes") } id := strconv.Itoa(n.ID) h := p.Href("code", n.File) sep := "?" if strings.Contains(h, "?") { sep = "&" } h += sep + "note=" + id if n.Start > 0 { h += "#L" + strconv.Itoa(n.Start) } return h } // newTemplates parses the page templates. The highlighter comes along because // note bodies are markdown, and a fenced code block in a note is painted by // the same grammars as the code view. func newTemplates(hl *highlighter) *template.Template { md := newMarkdown(hl) funcs := template.FuncMap{ "short": short, "md": md.renderAt, "band": func(a, b uint8) bool { return b != 0 && a&b != 0 }, "lines": func(n *Note) string { if n.Start <= 0 { return "whole file" } if n.End > n.Start { return ":" + strconv.Itoa(n.Start) + "-" + strconv.Itoa(n.End) } return ":" + strconv.Itoa(n.Start) }, "base": func(p string) string { if i := strings.LastIndex(p, "/"); i >= 0 { return p[i+1:] } return p }, "dict": func(kv ...any) map[string]any { m := map[string]any{} for i := 0; i+1 < len(kv); i += 2 { m[kv[i].(string)] = kv[i+1] } return m }, } tpl, err := template.New("layout.tmpl").Funcs(funcs).ParseFS(tfs, "templates/*.tmpl") if err != nil { fatal("%v", err) } return tpl } func short(s string) string { if len(s) > 8 { return s[:8] } return s }