summaryrefslogtreecommitdiff
path: root/vrsite/render.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-01 16:18:19 -0300
committerGabriel Schneider <[email protected]>2026-08-01 18:21:40 -0300
commit151ea12251ed876aa87bb486da1bc337e25369e9 (patch)
tree0363d90b5df6fc8b17b1737ef2793ba3825ba4d4 /vrsite/render.go
parent8ec4e135e318c7799b2576a8729493c063da4a97 (diff)
downloadnotevi-151ea12251ed876aa87bb486da1bc337e25369e9.tar.gz
notevi-151ea12251ed876aa87bb486da1bc337e25369e9.zip
Diffstat (limited to 'vrsite/render.go')
-rw-r--r--vrsite/render.go147
1 files changed, 129 insertions, 18 deletions
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 <ol> 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("</ol>")
}
- rem := f.Total - (ln - 1)
+ rem := to - (ln - 1)
if rem > chunkLines {
rem = chunkLines
}
- b.WriteString(`<ol class="code" style="counter-reset: ln `)
- writeInt(ln - 1)
- b.WriteString(`; --h: `)
+ b.WriteString(`<ol class="code" style="--h: `)
writeInt(rem * lineHeightPx)
b.WriteString(`px">`)
}
b.WriteString(`<li id="L`)
writeInt(ln)
b.WriteByte('"')
+ // Where the chunk's numbering starts. It has to be counter-set on the
+ // first line rather than counter-reset on the <ol>, 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("</li>")
start = end + 1
}
- if f.Total > 0 {
- b.WriteString("</ol>")
+ b.WriteString("</ol>")
+ 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
}