From 151ea12251ed876aa87bb486da1bc337e25369e9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 1 Aug 2026 16:18:19 -0300 Subject: --- vrsite/render.go | 147 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 129 insertions(+), 18 deletions(-) (limited to 'vrsite/render.go') diff --git a/vrsite/render.go b/vrsite/render.go index f18c3a8..61b1a1a 100644 --- a/vrsite/render.go +++ b/vrsite/render.go @@ -58,34 +58,66 @@ func (f *File) render(src []byte, hl *highlighter) { } } } + 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 - b.Grow(len(src) + len(src)/2 + f.Total*48) + 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) } - start := 0 - for ln := 1; ln <= f.Total; ln++ { + for ln := from; ln <= to; ln++ { end := start for end < len(src) && src[end] != '\n' { end++ } - if (ln-1)%chunkLines == 0 { - if ln > 1 { + opens := (ln-from)%chunkLines == 0 + if opens { + if ln > from { b.WriteString("
") } - rem := f.Total - (ln - 1) + 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] @@ -110,14 +142,64 @@ func (f *File) render(src []byte, hl *highlighter) { b.WriteByte('"') } b.WriteByte('>') - emitLine(&b, src[start:end], classes[start:end], hl.classNames) + emitLine(&b, src[start:end], classes[start:end], names) b.WriteString("
  2. ") start = end + 1 } - if f.Total > 0 { - b.WriteString("
") + 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++ } - f.Body = template.HTML(b.String()) + 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 @@ -307,16 +389,45 @@ type paint struct { cls int16 } -// classify is safe for concurrent use: the shared per-language query is -// immutable, and parser and cursor are per-call. Extensions no grammar claims -// come back unclassified rather than guessed at. +// 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) classify(path string, src []byte) []int16 { +func (h *highlighter) classifyWith(l *language, src []byte) []int16 { classes := make([]int16, len(src)) - l := h.byExt[strings.ToLower(filepath.Ext(path))] if l == nil { return classes } -- cgit v1.3