From 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 2 Aug 2026 23:43:08 -0300 Subject: rebrand to notevi: one CLI over the jj sidecar vr and vrsite become a single binary. vrsite/ folds into a web package in one module (0x4200.cafe/notevi); "notevi web" serves and exports exactly what vrsite did, and "notevi read/grep/note/query" is unchanged. The sidecar is renamed with it: notevi_log, notevi-log.jsonl, and the description "private: notevi log". The pre-rebrand names are still recognized, so an old repository opens and reads; it is renamed in place on the first write, or up front with "notevi migrate DIR...". That rename cannot be a single mv inside jj run. jj only auto-tracks a *new* file in the run working copy below a size limit it does not take from the command line, so writing a whole log under a name the change has never held is silently dropped while jj reports success. ensureLogFile creates the file empty first and lets every later byte be a modification of a tracked file, which snapshots at any size; that also fixes the same latent bug when importing a large legacy vr-log.jsonl. Adds a bem-te-vi mark (favicon and nav brand) and a README. Co-Authored-By: Claude Opus 5 (1M context) --- web/render.go | 462 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 web/render.go (limited to 'web/render.go') diff --git a/web/render.go b/web/render.go new file mode 100644 index 0000000..0bfbdce --- /dev/null +++ b/web/render.go @@ -0,0 +1,462 @@ +// render.go — source to HTML: tree-sitter highlighting, coverage classes and +// note anchors baked into one
    per chunk. +package web + +import ( + "bytes" + "html/template" + "path/filepath" + "sort" + "strconv" + "strings" + "unsafe" + + tszig "github.com/tree-sitter-grammars/tree-sitter-zig/bindings/go" + sitter "github.com/tree-sitter/go-tree-sitter" + tsbash "github.com/tree-sitter/tree-sitter-bash/bindings/go" + tsc "github.com/tree-sitter/tree-sitter-c/bindings/go" + tscpp "github.com/tree-sitter/tree-sitter-cpp/bindings/go" + tsgo "github.com/tree-sitter/tree-sitter-go/bindings/go" + tsjs "github.com/tree-sitter/tree-sitter-javascript/bindings/go" + tsjson "github.com/tree-sitter/tree-sitter-json/bindings/go" + tspython "github.com/tree-sitter/tree-sitter-python/bindings/go" + tsrust "github.com/tree-sitter/tree-sitter-rust/bindings/go" + tsts "github.com/tree-sitter/tree-sitter-typescript/bindings/go" +) + +// Lines per content-visibility chunk: the browser skips layout and paint of +// offscreen chunks wholesale, instead of managing one containment context +// per line. +const chunkLines = 800 + +// approximate rendered line height, for contain-intrinsic-size placeholders +const lineHeightPx = 19 + +// render builds the code HTML:
      chunks of chunkLines lines, each line an +//
    1. with syntax spans, coverage classes and note anchors. One pre-sized +// builder, no fmt, minimal escaping — large files are browser-bound, so the +// output is kept as small and flat as possible. +func (f *File) render(src []byte, hl *highlighter) { + classes := hl.classify(f.Path, src) + f.Total = bytes.Count(src, []byte{'\n'}) + if len(src) > 0 && src[len(src)-1] != '\n' { + f.Total++ + } + f.Covered = 0 + for i := 1; i < len(f.Cov) && i <= f.Total; i++ { + if f.Cov[i] != 0 { + f.Covered++ + } + } + if f.Total > 0 { + f.Pct = f.Covered * 100 / f.Total + } + sort.Slice(f.Notes, func(i, j int) bool { return f.Notes[i].Start < f.Notes[j].Start }) + for ni, n := range f.Notes { + for l := n.Start; l <= n.End && l > 0; l++ { + if _, taken := f.NoteAt[l]; !taken { + f.NoteAt[l] = ni + } + } + } + f.Body = f.emit(src, classes, hl.classNames, 1, f.Total) +} + +// emit writes lines [from..to] as
        chunks. from is where the chunking (and +// so the browser's content-visibility windows) starts counting, which is what +// lets an excerpt come out as one chunk rather than a slice of the file's. +func (f *File) emit(src []byte, classes []int16, names []string, from, to int) template.HTML { + if from < 1 { + from = 1 + } + if to > f.Total { + to = f.Total + } + if from > to { + return "" + } + start := 0 + for ln := 1; ln < from; ln++ { // skip to the first line asked for + start = pastEOL(src, start) + } + stop := start + for ln := from; ln <= to; ln++ { + stop = pastEOL(src, stop) + } + var b strings.Builder + span := stop - start + b.Grow(span + span/2 + (to-from+1)*48) + var num []byte + writeInt := func(n int) { num = strconv.AppendInt(num[:0], int64(n), 10); b.Write(num) } + for ln := from; ln <= to; ln++ { + end := start + for end < len(src) && src[end] != '\n' { + end++ + } + opens := (ln-from)%chunkLines == 0 + if opens { + if ln > from { + b.WriteString("
      ") + } + rem := to - (ln - 1) + if rem > chunkLines { + rem = chunkLines + } + b.WriteString(`
        `) + } + b.WriteString(`
      1. , because + // content-visibility: auto brings style containment with it and a + // contained element's own counter-reset comes out as 0 no matter what + // value it names — so every chunk but the first would count from 1. + if opens { + b.WriteString(` style="counter-set: ln `) + writeInt(ln) + b.WriteByte('"') + } + mask := uint8(0) + if ln < len(f.Cov) { + mask = f.Cov[ln] + } + ni, noted := f.NoteAt[ln] + if mask != 0 || noted { + b.WriteString(` class="`) + if mask != 0 { + b.WriteString("cov am") + writeInt(int(mask)) + if noted { + b.WriteString(" noted") + } + } else { + b.WriteString("noted") + } + b.WriteByte('"') + } + if noted { + b.WriteString(` data-note="note-`) + writeInt(f.Notes[ni].ID) + b.WriteByte('"') + } + b.WriteByte('>') + emitLine(&b, src[start:end], classes[start:end], names) + b.WriteString("
      2. ") + start = end + 1 + } + b.WriteString("
      ") + return template.HTML(b.String()) +} + +// pastEOL is the offset just past the line starting at i. +func pastEOL(src []byte, i int) int { + for i < len(src) && src[i] != '\n' { + i++ + } + if i < len(src) { + i++ + } + return i +} + +// excerptContext is how many lines either side of a note's range its own page +// shows, so the noted lines are read in context rather than in isolation. +const excerptContext = 4 + +// excerptFor renders just the lines a note is about, the way the code view +// would: the file's real line numbers, its coverage tint, and the noted lines +// marked. This is what lets a shared permalink stand on its own — whoever +// opens it sees the note and the code it is about together, without having to +// find their way into the file first. +// +// cov comes from the model and is read-only here, so the caller can pass the +// live *File's slice rather than copying it. +func excerptFor(path string, src []byte, cov []uint8, hl *highlighter, n *Note) (body template.HTML, from, to int) { + total := bytes.Count(src, []byte{'\n'}) + if len(src) > 0 && src[len(src)-1] != '\n' { + total++ + } + end := n.End + if end < n.Start { + end = n.Start + } + if n.Start < 1 || total == 0 { + return "", 0, 0 + } + f := &File{Path: path, Cov: cov, Total: total, Notes: []*Note{n}, NoteAt: map[int]int{}} + for l := n.Start; l <= end; l++ { + f.NoteAt[l] = 0 + } + from, to = n.Start-excerptContext, end+excerptContext + if from < 1 { + from = 1 + } + if to > total { + to = total + } + if from > total { + return "", 0, 0 + } + return f.emit(src, hl.classify(path, src), hl.classNames, from, to), from, to +} + +// emitLine writes one line's spans, merging runs of the same class across +// whitespace-only gaps ("pub fn" is one span, not two) to keep the DOM flat. +func emitLine(b *strings.Builder, line []byte, cls []int16, names []string) { + open := int16(0) + i := 0 + for i < len(line) { + j := i + c := cls[i] + for j < len(line) && cls[j] == c { + j++ + } + if c == 0 { + allSpace := true + for k := i; k < j; k++ { + if line[k] != ' ' && line[k] != '\t' { + allSpace = false + break + } + } + if !(allSpace && open != 0 && j < len(line) && cls[j] == open) { + if open != 0 { + b.WriteString("") + open = 0 + } + } + escapeTo(b, line[i:j]) + } else { + if open != c { + if open != 0 { + b.WriteString("") + } + b.WriteString(``) + open = c + } + escapeTo(b, line[i:j]) + } + i = j + } + if open != 0 { + b.WriteString("") + } +} + +// escapeTo escapes the three characters that matter in text content. +func escapeTo(b *strings.Builder, s []byte) { + last := 0 + for i, c := range s { + var rep string + switch c { + case '&': + rep = "&" + case '<': + rep = "<" + case '>': + rep = ">" + default: + continue + } + b.Write(s[last:i]) + b.WriteString(rep) + last = i + 1 + } + b.Write(s[last:]) +} + +// ── highlighting ────────────────────────────────────────────────────────── + +// grammars lists every language the site can highlight: the extensions it +// claims, the vendored queries that paint it, and the cgo grammar. Query files +// concatenate the way upstream composes them — C's patterns underpin C++, +// JavaScript's underpin TypeScript — and the concatenation keeps the +// later-patterns-win order, so the supplement wins where both match. +var grammars = []struct { + exts []string + scms []string + lang func() unsafe.Pointer +}{ + {[]string{".rs"}, []string{"rust"}, tsrust.Language}, + {[]string{".go"}, []string{"go"}, tsgo.Language}, + {[]string{".py", ".pyi"}, []string{"python"}, tspython.Language}, + {[]string{".js", ".mjs", ".cjs", ".jsx"}, []string{"javascript", "javascript-params", "jsx"}, tsjs.Language}, + {[]string{".ts", ".mts", ".cts"}, []string{"javascript", "typescript"}, tsts.LanguageTypescript}, + {[]string{".tsx"}, []string{"javascript", "typescript", "jsx"}, tsts.LanguageTSX}, + {[]string{".c"}, []string{"c"}, tsc.Language}, + // .h is C++'s upstream claim, and the C++ grammar is a superset: C headers + // parse the same under it, C++ headers only parse under it + {[]string{".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"}, []string{"c", "cpp"}, tscpp.Language}, + {[]string{".zig"}, []string{"zig"}, tszig.Language}, + {[]string{".json"}, []string{"json"}, tsjson.Language}, + {[]string{".sh", ".bash"}, []string{"bash"}, tsbash.Language}, +} + +// language is one grammar's share of the highlighter. Everything here is built +// at startup and never written again, which is what lets classify run from any +// number of goroutines at once. +type language struct { + lang *sitter.Language + query *sitter.Query + captureCls []int16 // capture index -> class id +} + +type highlighter struct { + byExt map[string]*language + classNames []string +} + +// resolveKey finds the theme syntax key for a capture name by stripping dot +// segments; "" when the theme has no entry at all. +func resolveKey(t *Theme, name string) string { + for key := name; key != ""; { + if s, ok := t.Syntax[key]; ok && s.Color != "" { + return key + } + if i := strings.LastIndex(key, "."); i >= 0 { + key = key[:i] + } else { + break + } + } + return "" +} + +// nearFg reports whether a capture would render indistinguishably from plain +// foreground text in the theme. Spans for such captures are pure DOM weight. +func nearFg(t *Theme, name string) bool { + key := resolveKey(t, name) + if key == "" { + return true // no color -> falls through to fg anyway + } + return colorDist(hex6(t.Syntax[key].Color), hex6(t.Fg)) < 2000 +} + +func newHighlighter(dark, light *Theme) *highlighter { + h := &highlighter{byExt: map[string]*language{}, classNames: []string{""}} + for _, g := range grammars { + var scm strings.Builder + for _, name := range g.scms { + b, err := tfs.ReadFile("assets/" + name + ".scm") + if err != nil { + fatal("%v", err) + } + scm.Write(b) + } + l := &language{lang: sitter.NewLanguage(g.lang())} + q, qerr := sitter.NewQuery(l.lang, scm.String()) + if qerr != nil { + fatal("%s query: %v", strings.Join(g.scms, "+"), qerr) + } + l.query = q + for _, name := range q.CaptureNames() { + l.captureCls = append(l.captureCls, h.classFor(dark, light, name)) + } + for _, ext := range g.exts { + h.byExt[ext] = l + } + } + return h +} + +// classFor interns the class id of a capture name, which doubles as a zed +// theme syntax key. Captures both themes paint (nearly) as foreground get id 0 +// and no span at all — punctuation and friends are the bulk of all spans. +func (h *highlighter) classFor(dark, light *Theme, name string) int16 { + if nearFg(dark, name) && nearFg(light, name) { + return 0 + } + key := resolveKey(dark, name) + if key == "" { + key = resolveKey(light, name) + } + cls := strings.ReplaceAll(key, ".", "-") + for i, n := range h.classNames { + if n == cls { + return int16(i) + } + } + h.classNames = append(h.classNames, cls) + return int16(len(h.classNames) - 1) +} + +type paint struct { + start, end uint + pattern uint + cls int16 +} + +// classify paints a file. Extensions no grammar claims come back unclassified +// rather than guessed at. +func (h *highlighter) classify(path string, src []byte) []int16 { + return h.classifyWith(h.byExt[strings.ToLower(filepath.Ext(path))], src) +} + +// fenceAliases maps the fence languages that are not already one of the +// extensions in grammars. Most are ("go", "rs", "py", "ts", "json", "bash"), +// so the grammar table does the bulk of the lookup on its own. +var fenceAliases = map[string]string{ + "golang": ".go", "rust": ".rs", "python": ".py", "python3": ".py", + "javascript": ".js", "node": ".js", "typescript": ".ts", + "c++": ".cc", "shell": ".sh", "zsh": ".sh", "console": ".sh", + "jsonc": ".json", "json5": ".json", +} + +// classifyLang paints a fenced code block, whose language arrives as a name +// rather than a path. Unknown or absent languages come back unclassified, the +// same as an extension no grammar claims. +func (h *highlighter) classifyLang(lang string, src []byte) []int16 { + if h == nil { + return make([]int16, len(src)) + } + name := strings.ToLower(strings.TrimSpace(lang)) + l := h.byExt["."+name] + if l == nil { + l = h.byExt[fenceAliases[name]] + } + return h.classifyWith(l, src) +} + +// classifyWith is safe for concurrent use: the shared per-language query is +// immutable, and parser and cursor are per-call. +// +// Matches arrive already filtered by their #eq?/#match?/#any-of? predicates — +// go-tree-sitter evaluates those in QueryMatches.Next — so every capture here +// is one the query really meant. +func (h *highlighter) classifyWith(l *language, src []byte) []int16 { + classes := make([]int16, len(src)) + if l == nil { + return classes + } + parser := sitter.NewParser() + defer parser.Close() + parser.SetLanguage(l.lang) + tree := parser.Parse(src, nil) + defer tree.Close() + + qc := sitter.NewQueryCursor() + defer qc.Close() + matches := qc.Matches(l.query, tree.RootNode(), src) + var paints []paint + for m := matches.Next(); m != nil; m = matches.Next() { + for _, c := range m.Captures { + if cls := l.captureCls[c.Index]; cls != 0 { + paints = append(paints, paint{c.Node.StartByte(), c.Node.EndByte(), m.PatternIndex, cls}) + } + } + } + // later patterns in the query paint over earlier ones; two captures of one + // pattern must not overlap, since this sort leaves their order arbitrary + sort.Slice(paints, func(i, j int) bool { return paints[i].pattern < paints[j].pattern }) + for _, p := range paints { + for i := p.start; i < p.end && int(i) < len(classes); i++ { + classes[i] = p.cls + } + } + return classes +} -- cgit v1.3