summaryrefslogtreecommitdiff
path: root/web/static.go
diff options
context:
space:
mode:
Diffstat (limited to 'web/static.go')
-rw-r--r--web/static.go247
1 files changed, 247 insertions, 0 deletions
diff --git a/web/static.go b/web/static.go
new file mode 100644
index 0000000..3f3e766
--- /dev/null
+++ b/web/static.go
@@ -0,0 +1,247 @@
+// static.go — the offline export: the same pages written out as files.
+//
+// Everything that needs a server (comments, live activity, jj queries) is
+// omitted; the trace/notes toggle, the file finder and the note filters all
+// keep working, since they run in the browser.
+package web
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+)
+
+func run(dir string, name string, args ...string) []byte {
+ cmd := exec.Command(name, args...)
+ cmd.Dir = dir
+ out, err := cmd.Output()
+ if err != nil {
+ if ee, ok := err.(*exec.ExitError); ok {
+ os.Stderr.Write(ee.Stderr)
+ }
+ fatal("%s %s: %v", name, strings.Join(args, " "), err)
+ }
+ return out
+}
+
+func buildSite(log logStore, repoAbs, out, title string, theme, light *Theme, themes *themeSet) {
+ start := time.Now()
+ entries := readLog(log)
+ ranked := buildModel(title, entries, "").Ranked
+ if len(ranked) == 0 {
+ fatal("log has no jj change ids")
+ }
+ // the most-logged change this repo actually has: a log can span repos, and
+ // the busiest change in it may belong to another one
+ var change string
+ var list []byte
+ for _, c := range ranked {
+ out, err := jjRun(repoAbs, "file", "list", "-r", c)
+ if err == nil {
+ change, list = c, out
+ break
+ }
+ }
+ if change == "" {
+ fatal("none of the log's %d changes are in %s — logs are per repository; point -repo at the one the trace was recorded in",
+ len(ranked), repoAbs)
+ }
+ // an export is a picture of one change, so it holds only what was
+ // recorded there; the notes page says how much of the log that leaves out
+ site := buildModel(title, entries, change)
+ site.Change = change
+ site.Theme, site.Light, site.Repo = theme, light, repoAbs
+ site.All = site.filesAt(strings.Split(strings.TrimRight(string(list), "\n"), "\n"), change)
+
+ commit := strings.TrimSpace(string(mustJJ(repoAbs, "log", "-r", site.Change, "--no-graph", "-T", "commit_id")))
+ tmp, err := os.MkdirTemp("", "notevi-")
+ if err != nil {
+ fatal("%v", err)
+ }
+ defer os.RemoveAll(tmp)
+ archive := exec.Command("git", "-C", repoAbs, "archive", commit)
+ untar := exec.Command("tar", "-x", "-C", tmp)
+ untar.Stdin, _ = archive.StdoutPipe()
+ untar.Stderr = os.Stderr
+ if err := untar.Start(); err != nil {
+ fatal("tar: %v", err)
+ }
+ if err := archive.Run(); err != nil {
+ fatal("git archive %s: %v", short(commit), err)
+ }
+ if err := untar.Wait(); err != nil {
+ fatal("tar: %v", err)
+ }
+
+ writeSite(site, out, tmp, themes)
+ stubs := 0
+ for _, f := range site.All {
+ if f.Stub != "" {
+ stubs++
+ }
+ }
+ fmt.Printf("notevi web: %d files (%d traced, %d not rendered), %d notes, %d agents in %s -> %s\n",
+ len(site.All), len(site.Order), stubs, len(site.Notes), len(site.Agents),
+ time.Since(start).Round(time.Millisecond), out)
+}
+
+func writeSite(site *Site, out, srcDir string, themes *themeSet) {
+ if themes == nil {
+ themes = &themeSet{}
+ }
+ hl := newHighlighter(site.Theme, site.Light)
+ tpl := newTemplates(hl)
+ if err := os.MkdirAll(out, 0o755); err != nil {
+ fatal("%v", err)
+ }
+
+ each := func(fn func(f *File)) {
+ sem := make(chan struct{}, runtime.NumCPU())
+ var wg sync.WaitGroup
+ for _, f := range site.All {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(f *File) {
+ defer func() { <-sem; wg.Done() }()
+ fn(f)
+ }(f)
+ }
+ wg.Wait()
+ }
+ // phase A: content — coverage percentages must exist before any tree
+ // or index below is written
+ each(func(f *File) {
+ src, err := os.ReadFile(filepath.Join(srcDir, f.Path))
+ switch {
+ case err != nil:
+ f.Stub = "not present in the archived commit"
+ case bytes.IndexByte(src, 0) >= 0:
+ f.Stub = "binary file — not rendered"
+ case len(src) > 2<<20:
+ f.Stub = fmt.Sprintf("file too large to render (%d KB)", len(src)/1024)
+ default:
+ f.render(src, hl)
+ }
+ })
+
+ cf := mustCreate(filepath.Join(out, "style.css"))
+ if err := renderCSS(cf, site); err != nil {
+ fatal("css: %v", err)
+ }
+ cf.Close()
+ for _, name := range []string{"app.js", "bemtevi.svg"} {
+ b, err := tfs.ReadFile("assets/" + name)
+ if err != nil {
+ fatal("%v", err)
+ }
+ if err := os.WriteFile(filepath.Join(out, name), b, 0o644); err != nil {
+ fatal("%v", err)
+ }
+ }
+ // the picker travels with the export: a <script src>/<link> pair loads
+ // from the filesystem, which is why the menu is a script and not JSON
+ for name, b := range map[string][]byte{"themes.css": themes.CSS, "themes.js": themes.JS} {
+ if err := os.WriteFile(filepath.Join(out, name), b, 0o644); err != nil {
+ fatal("%v", err)
+ }
+ }
+
+ // shared tree fragment: the only nav tree, fetched by every page; "@@" is
+ // replaced with the page root by app.js, since the fragment is fetched
+ // from any page depth
+ treeFrag := renderTree(site.All, func(p string) string { return "@@code/" + p + ".html" })
+ if err := os.WriteFile(filepath.Join(out, "tree.html"), []byte(treeFrag), 0o644); err != nil {
+ fatal("%v", err)
+ }
+
+ facets := site.Facets()
+ var info *Row
+ if rows, err := LogRows(site.Repo, site.Change, "", 1); err == nil {
+ for _, row := range rows {
+ if !row.Blank() {
+ r := row
+ info = &r
+ break
+ }
+ }
+ }
+ render := func(path string, p *page) {
+ p.Site, p.Facets = site, facets
+ p.ChangeID, p.Default, p.ChangeInfo = site.Change, site.Change, info
+ p.Scope, p.Here = "change", site.NotesAt[site.Change]
+ p.Elsewhere = site.NotesTotal - p.Here
+ p.Files = site.All
+ f := mustCreate(path)
+ if err := tpl.Execute(f, p); err != nil {
+ fatal("render %s: %v", path, err)
+ }
+ f.Close()
+ }
+
+ latest := site.FilterNotes(NoteFilter{})
+ if len(latest) > 24 {
+ latest = latest[:24]
+ }
+ render(filepath.Join(out, "index.html"), &page{Kind: "index", Title: site.Title, Notes: latest})
+
+ fullTree := renderTree(site.All, func(p string) string { return "code/" + p + ".html" })
+ render(filepath.Join(out, "files.html"), &page{Kind: "files", Title: "files — " + site.Title,
+ Tree: template.HTML(fullTree)})
+
+ render(filepath.Join(out, "notes.html"), &page{Kind: "notes", Title: "notes — " + site.Title,
+ Notes: site.FilterNotes(NoteFilter{})})
+
+ // phase B: every file page, in parallel
+ each(func(f *File) {
+ dst := filepath.Join(out, "code", f.Path+".html")
+ os.MkdirAll(filepath.Dir(dst), 0o755)
+ depth := strings.Count(f.Path, "/") + 1
+ render(dst, &page{Kind: "file", Title: f.Path + " — " + site.Title,
+ Root: strings.Repeat("../", depth), Current: f.Path, File: f})
+ })
+
+ // phase C: a page per note, so a permalink handed to someone else opens
+ // in the export exactly as it does in the live app. The source is read
+ // again rather than kept from phase A: an export is a batch job, and the
+ // alternative is holding every file in memory for the sake of a few
+ // hundred excerpts.
+ if err := os.MkdirAll(filepath.Join(out, "note"), 0o755); err != nil {
+ fatal("%v", err)
+ }
+ for _, n := range site.Notes {
+ p := &page{Kind: "note", Title: noteTitle(n) + " — " + site.Title,
+ Root: "../", Current: n.File, Note: n, Thread: site.NoteThread(n.ID)}
+ if n.File != "" && n.Start > 0 {
+ src, err := os.ReadFile(filepath.Join(srcDir, n.File))
+ switch {
+ case err != nil:
+ p.ExErr = "not present in the archived commit"
+ case bytes.IndexByte(src, 0) >= 0:
+ p.ExErr = "binary file — not rendered"
+ default:
+ var cov []uint8
+ if f, ok := site.Files[n.File]; ok {
+ cov = f.Cov
+ }
+ p.Excerpt, p.ExFrom, p.ExTo = excerptFor(n.File, src, cov, hl, n)
+ }
+ }
+ render(filepath.Join(out, "note", strconv.Itoa(n.ID)+".html"), p)
+ }
+}
+
+func mustCreate(path string) *os.File {
+ f, err := os.Create(path)
+ if err != nil {
+ fatal("%v", err)
+ }
+ return f
+}