diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-02 23:43:08 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-03 09:54:39 -0300 |
| commit | 5ec72f8723d795d0b02d7e9ebb9f555f0c7e6a2e (patch) | |
| tree | f12f4b700fff4ff6f7e818d2a542672ba0b8d76e /web/markdown.go | |
| parent | a9263daee9413c5eff3c1f1bebcd224442fd8685 (diff) | |
| download | notevi-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.go | 259 |
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(¬eRenderer{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") +} |
