// 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 main import ( "bytes" "fmt" "html/template" "os" "os/exec" "path/filepath" "runtime" "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(logPath, repoAbs, out, title string, theme, light *Theme) { start := time.Now() site := buildModel(title, readLog(logPath)) site.Theme, site.Light, site.Repo = theme, light, repoAbs if site.Change == "" { fatal("log has no jj change ids") } // every file in the repo at the site change; content comes from one // git archive of the commit instead of a jj spawn per file list, err := jjRun(repoAbs, "file", "list", "-r", site.Change) if err != nil { fmt.Fprintln(os.Stderr, err) fatal("change %s is not in %s — logs are per repository; point -repo at the one the trace was recorded in", short(site.Change), repoAbs) } site.All = site.filesAt(strings.Split(strings.TrimRight(string(list), "\n"), "\n"), site.Change) commit := strings.TrimSpace(string(mustJJ(repoAbs, "log", "-r", site.Change, "--no-graph", "-T", "commit_id"))) tmp, err := os.MkdirTemp("", "vrsite-") 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) stubs := 0 for _, f := range site.All { if f.Stub != "" { stubs++ } } fmt.Printf("vrsite: %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) { tpl := newTemplates() 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 hl := newHighlighter(site.Theme, site.Light) 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"} { 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) } } // 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() render := func(path string, p *page) { p.Site, p.Facets, p.ChangeID = site, facets, site.Change 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}) }) } func mustCreate(path string) *os.File { f, err := os.Create(path) if err != nil { fatal("%v", err) } return f }