// 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
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 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" loses a word, and the block form
// additionally stops parsing markdown for the rest of the paragraph — a note
// that opens with a quoted 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("
" + template.HTMLEscapeString(text) + "
")
}
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
// runs as the code view, so the stylesheet already knows how to color it; the
// -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(`")
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("
\n")
}