summaryrefslogtreecommitdiff
path: root/web/markdown.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:43:08 -0300
committerGabriel Schneider <[email protected]>2026-08-03 09:54:39 -0300
commit5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch)
treef12f4b700fff4ff6f7e818d2a542672ba0b8d76e /web/markdown.go
parenta9263daee9413c5eff3c1f1bebcd224442fd8685 (diff)
downloadnotevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.tar.gz
notevi-5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e.zip
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) <[email protected]>
Diffstat (limited to 'web/markdown.go')
-rw-r--r--web/markdown.go259
1 files changed, 259 insertions, 0 deletions
diff --git a/web/markdown.go b/web/markdown.go
new file mode 100644
index 0000000..476e67c
--- /dev/null
+++ b/web/markdown.go
@@ -0,0 +1,259 @@
+// markdown.go — note text is markdown. Agents write prose about code:
+// backticked identifiers, bullet lists, the occasional fenced snippet, and
+// links out. Rendering it as one escaped <p> threw all of that away.
+//
+// Two things make markdown fit text nobody wrote for a browser:
+//
+// - angle brackets are never markup. In a note they are code —
+// RwLock<SysvarCache> is a type — so the HTML parsers come out and the
+// brackets survive as the characters they were typed as.
+// - fenced code goes through the same tree-sitter highlighter as the code
+// view, so a snippet in a note is painted by the reader's theme exactly
+// like the file it was copied from.
+package web
+
+import (
+ "bytes"
+ stdhtml "html"
+ "html/template"
+ "net/url"
+ "path"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/extension"
+ mdparser "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer"
+ "github.com/yuin/goldmark/renderer/html"
+ mdtext "github.com/yuin/goldmark/text"
+ "github.com/yuin/goldmark/util"
+)
+
+// markdown renders note text. goldmark is safe for concurrent use, which the
+// server (a goroutine per request) and the export (a goroutine per file) both
+// rely on.
+type markdown struct{ md goldmark.Markdown }
+
+// newMarkdown builds the note renderer. GFM adds the things people actually
+// type — tables, ~~strikethrough~~, bare URLs, task lists — and hard wraps
+// keep a newline a newline, since a note is typed into a textarea or passed on
+// a command line, not authored as a document.
+func newMarkdown(hl *highlighter) *markdown {
+ md := goldmark.New(
+ goldmark.WithParser(notesParser()),
+ goldmark.WithExtensions(extension.GFM),
+ goldmark.WithRendererOptions(html.WithHardWraps()),
+ )
+ // lower priority number wins: this one replaces the stock code-block
+ // renderers, everything else stays goldmark's
+ md.Renderer().AddOptions(renderer.WithNodeRenderers(
+ util.Prioritized(&noteRenderer{hl: hl}, 100)))
+ return &markdown{md: md}
+}
+
+// goldmark's priorities for the two parsers that read angle brackets as
+// markup, from mdparser.DefaultBlockParsers and mdparser.DefaultInlineParsers.
+const (
+ htmlBlockPriority = 900
+ rawHTMLPriority = 400
+)
+
+// notesParser is goldmark's default parser with those two dropped. Leaving
+// them in costs the note either way: goldmark's safe default deletes raw HTML
+// outright, so "RwLock<SysvarCache>" loses a word, and the block form
+// additionally stops parsing markdown for the rest of the paragraph — a note
+// that opens with a quoted <div> would render its links and lists as source.
+// Without the parsers a bracket is just a character the text renderer escapes,
+// which is both what the writer meant and the one thing a browser cannot act
+// on. Dropping by priority keeps every other default, including any upstream
+// adds later.
+func notesParser() mdparser.Parser {
+ return mdparser.NewParser(
+ mdparser.WithBlockParsers(without(mdparser.DefaultBlockParsers(), htmlBlockPriority)...),
+ mdparser.WithInlineParsers(without(mdparser.DefaultInlineParsers(), rawHTMLPriority)...),
+ mdparser.WithParagraphTransformers(mdparser.DefaultParagraphTransformers()...),
+ )
+}
+
+func without(vs []util.PrioritizedValue, priority int) []util.PrioritizedValue {
+ out := vs[:0]
+ for _, v := range vs {
+ if v.Priority != priority {
+ out = append(out, v)
+ }
+ }
+ return out
+}
+
+// newNoteLinkParser is shared across one model build. It uses the same parser
+// and GFM extensions as rendered note bodies, so only links the reader can
+// actually click become mentions (code spans and plain prose do not).
+func newNoteLinkParser() mdparser.Parser {
+ return goldmark.New(
+ goldmark.WithParser(notesParser()),
+ goldmark.WithExtensions(extension.GFM),
+ ).Parser()
+}
+
+func noteLinks(p mdparser.Parser, source string) []int {
+ if !strings.Contains(source, "note") {
+ return nil
+ }
+ src := []byte(source)
+ doc := p.Parse(mdtext.NewReader(src))
+ seen := map[int]bool{}
+ var ids []int
+ _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering || n.Kind() != ast.KindLink {
+ return ast.WalkContinue, nil
+ }
+ id := noteLinkID(string(n.(*ast.Link).Destination))
+ if id > 0 && !seen[id] {
+ seen[id] = true
+ ids = append(ids, id)
+ }
+ return ast.WalkContinue, nil
+ })
+ return ids
+}
+
+// noteLinkID recognizes local links ending in note/N. Leading /, ./ and ../
+// components are all allowed; schemes and protocol-relative hosts are not.
+func noteLinkID(destination string) int {
+ u, err := url.Parse(strings.TrimSpace(destination))
+ if err != nil || u.Scheme != "" || u.Host != "" {
+ return 0
+ }
+ clean := path.Clean("/" + strings.TrimSuffix(u.Path, ".html"))
+ parts := strings.Split(strings.Trim(clean, "/"), "/")
+ if len(parts) < 2 || parts[len(parts)-2] != "note" {
+ return 0
+ }
+ id, err := strconv.Atoi(parts[len(parts)-1])
+ if err != nil || id < 1 {
+ return 0
+ }
+ return id
+}
+
+// render turns one note's text into HTML. A note is never worth losing to a
+// renderer error, so a failure falls back to the text as written.
+func (m *markdown) render(text string) template.HTML {
+ if strings.TrimSpace(text) == "" {
+ return ""
+ }
+ var b bytes.Buffer
+ b.Grow(len(text) + len(text)/2)
+ if err := m.md.Convert([]byte(text), &b); err != nil {
+ return template.HTML("<p>" + template.HTMLEscapeString(text) + "</p>")
+ }
+ return template.HTML(b.String())
+}
+
+var noteHrefRE = regexp.MustCompile(`href="([^"]+)"`)
+
+// renderAt canonicalizes note links for the page carrying the card. A note is
+// shown at several URL depths (/notes, /code/x, /note/N, and static variants),
+// so leaving its relative Markdown href untouched would make the mention work
+// in only one of those places.
+func (m *markdown) renderAt(text string, p *page) template.HTML {
+ body := m.render(text)
+ if body == "" || p == nil {
+ return body
+ }
+ out := noteHrefRE.ReplaceAllStringFunc(string(body), func(attr string) string {
+ destination := stdhtml.UnescapeString(attr[len(`href="`) : len(attr)-1])
+ id := noteLinkID(destination)
+ if id == 0 {
+ return attr
+ }
+ if !p.Live && p.Site != nil && p.Site.NoteByID(id) == nil {
+ return attr
+ }
+ href := p.Href("note", strconv.Itoa(id))
+ if href == "" {
+ return attr
+ }
+ return `href="` + template.HTMLEscapeString(href) + `"`
+ })
+ return template.HTML(out)
+}
+
+// noteRenderer overrides the two node kinds the stock renderer emits without
+// syntax colors. Everything else it renders is fine as goldmark writes it.
+type noteRenderer struct{ hl *highlighter }
+
+func (nr *noteRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindFencedCodeBlock, nr.renderFenced)
+ reg.Register(ast.KindCodeBlock, nr.renderIndented)
+}
+
+func (nr *noteRenderer) renderFenced(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkSkipChildren, nil
+ }
+ n := node.(*ast.FencedCodeBlock)
+ nr.code(w, blockLines(src, n), string(n.Language(src)))
+ return ast.WalkSkipChildren, nil
+}
+
+func (nr *noteRenderer) renderIndented(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkSkipChildren, nil
+ }
+ nr.code(w, blockLines(src, node), "")
+ return ast.WalkSkipChildren, nil
+}
+
+// blockLines joins a block node's source lines. They are contiguous in the
+// source, but only the segments are authoritative about where the block's
+// indentation was stripped.
+func blockLines(src []byte, n ast.Node) []byte {
+ lines := n.Lines()
+ var b bytes.Buffer
+ for i := 0; i < lines.Len(); i++ {
+ seg := lines.At(i)
+ b.Write(seg.Value(src))
+ }
+ return bytes.TrimRight(b.Bytes(), "\n")
+}
+
+// code writes one code block, syntax-painted when a grammar claims the fence's
+// language and plain when none does. It emits the same <span class="s-…">
+// runs as the code view, so the stylesheet already knows how to color it; the
+// <li>-per-line scaffolding does not come along, since a snippet has no line
+// numbers, no coverage and no notes of its own.
+func (nr *noteRenderer) code(w util.BufWriter, src []byte, lang string) {
+ w.WriteString(`<pre class="mdcode"`)
+ if lang != "" {
+ w.WriteString(` data-lang="`)
+ w.WriteString(template.HTMLEscapeString(lang))
+ w.WriteByte('"')
+ }
+ w.WriteString("><code>")
+
+ var b strings.Builder
+ b.Grow(len(src) + len(src)/4)
+ classes := nr.hl.classifyLang(lang, src)
+ names := []string(nil)
+ if nr.hl != nil {
+ names = nr.hl.classNames
+ }
+ for start := 0; start <= len(src); {
+ end := start
+ for end < len(src) && src[end] != '\n' {
+ end++
+ }
+ emitLine(&b, src[start:end], classes[start:end], names)
+ if end >= len(src) {
+ break
+ }
+ b.WriteByte('\n')
+ start = end + 1
+ }
+ w.WriteString(b.String())
+ w.WriteString("</code></pre>\n")
+}