// resolver.go — zit reference resolution (0002 §2 rework: git-ref hooks
// replaced with zit resolution). Runs AFTER sanitization, over text
// nodes only — the links it inserts are quarry-built, and code spans,
// pre blocks, and existing links are left alone.
package markup
import (
"bytes"
"fmt"
"regexp"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
var (
wikiRef = regexp.MustCompile(`\[\[([^\[\]|]{1,100})\]\]`)
hashRef = regexp.MustCompile(`\b[0-9a-f]{10,64}\b`)
)
// UserResolver is an OPTIONAL upgrade on Resolver: @name mentions that
// resolve to a known person render as a link in that person's hue —
// the same identity color the rest of the forge speaks.
type UserResolver interface {
// ResolveUser answers ("", 0, false) for names it does not know;
// an unresolved mention stays plain text, never a dead link.
ResolveUser(name string) (url string, hue int, ok bool)
}
var mentionRef = regexp.MustCompile(`(^|[^a-zA-Z0-9._-])@([a-zA-Z0-9][a-zA-Z0-9._-]{0,63})`)
// TokenResolver is an OPTIONAL upgrade on Resolver: the reference
// grammar of RFC 0011 §14 — chat sequences, repo-qualified tokens and
// pasted quarry URLs. Implementations answer false for anything they
// cannot resolve, INCLUDING a cross-repository token whose target the
// reader may not see, and the token stays plain text.
//
// The link this mints carries class="ref" and data-ref/data-ref-kind
// and nothing else. The preview is armed ONCE for the whole page, by
// delegated handlers on the layout's container that read data-ref off
// whichever anchor an event reached — so a body with two hundred
// references costs two hundred short attributes, not two hundred
// copies of the same expression.
type TokenResolver interface {
ResolveToken(t RefToken) (url string, ok bool)
}
// HashDescriber is an OPTIONAL upgrade on Resolver: a one-line human
// description for a resolvable hash (a check-in's comment). Described
// links carry it as a hover title and a visible excerpt.
type HashDescriber interface {
DescribeHash(prefix string) string
}
// TicketRef is what a TicketDescriber knows about the ticket a
// reference names: where it lives now, its number, and whether it is
// still open. It carries no title on purpose: a reader who may see the
// page a reference sits on need not be able to read tickets, and a link
// has only ever told them the ticket's address.
type TicketRef struct {
ID string // the ticket's full id, which the preview card is asked for
URL string // the ticket's page, after following a promotion
Number int // 0 when the repository numbers nothing
State string // "open", "review" or "closed"
Status string // the tracker's own word for it
Local bool // kept on this forge only, not in the repository's history
}
// TicketDescriber is an OPTIONAL upgrade on Resolver: a reference to a
// ticket (a bare hash, a typed #N, or a link to its page) reads as the
// ticket's number with its state beside it, so a list of references
// says which are done without opening each one.
type TicketDescriber interface {
DescribeTicket(t RefToken) (TicketRef, bool)
}
// ticketLabel is the text a ticket reference shows: its number when it
// has one and the author wrote only an id, otherwise what they wrote.
func ticketLabel(label string, tr TicketRef) string {
if tr.Number > 0 && (hexID.MatchString(label) || refNumber.MatchString(strings.TrimSpace(label))) {
return fmt.Sprintf("%d", tr.Number)
}
return label
}
var hexID = regexp.MustCompile(`^[0-9a-fA-F]{4,64}$`)
// ticketToken is the preview card's token for a described ticket: its
// full id, spelled as a ticket, so the card is asked about a ticket
// even where the hash alone would not say so (a local ticket is not in
// the index a bare hash is classified against).
func ticketToken(repo string, tr TicketRef) string {
id := tr.ID
if id == "" {
return ""
}
return RefToken{Repo: repo, Kind: RefTicket, ID: id}.Canonical()
}
// ticketPills are the state (and the local mark) set after a ticket
// reference, as quarry-built nodes on the far side of the sanitizer.
func ticketPills(tr TicketRef) []*html.Node {
pill := func(class, title, text string) *html.Node {
n := &html.Node{Type: html.ElementNode, Data: "span", DataAtom: atom.Span,
Attr: []html.Attribute{{Key: "class", Val: class}}}
if title != "" {
n.Attr = append(n.Attr, html.Attribute{Key: "title", Val: title})
}
n.AppendChild(&html.Node{Type: html.TextNode, Data: text})
return n
}
// The state is the icon in front of the number, not text after it.
var out []*html.Node
if tr.Local {
out = append(out, &html.Node{Type: html.TextNode, Data: " "},
pill("tref-local", "Kept on this forge only, not yet in the repository's history", "local"))
}
return out
}
// TicketRefHTML is a ticket reference as HTML for a caller that builds
// its own markup (the web's check-in comment linker): the same anchor
// and pills the renderer puts in prose, so the two never drift.
func TicketRefHTML(label string, tr TicketRef) string {
a := &html.Node{Type: html.ElementNode, Data: "a", DataAtom: atom.A,
Attr: []html.Attribute{{Key: "href", Val: tr.URL}, {Key: "class", Val: "tref"},
{Key: "data-ref", Val: ticketToken("", tr)}, {Key: "data-ref-kind", Val: RefTicket}}}
a.AppendChild(&html.Node{Type: html.TextNode, Data: ticketLabel(label, tr)})
ticketIcon(tr, ticketLabel(label, tr)).wear(a)
var b bytes.Buffer
html.Render(&b, a)
for _, n := range ticketPills(tr) {
html.Render(&b, n)
}
return b.String()
}
// excerpt clips s at n runes on a rune boundary, marking the cut.
func excerpt(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "…"
}
func resolveRefs(in []byte, r Resolver) []byte {
body := &html.Node{Type: html.ElementNode, Data: "body", DataAtom: atom.Body}
nodes, err := html.ParseFragment(bytes.NewReader(in), body)
if err != nil {
return in
}
var out bytes.Buffer
for _, n := range nodes {
walkResolve(n, r)
if err := html.Render(&out, n); err != nil {
return in
}
}
return out.Bytes()
}
var skipTags = map[string]bool{"a": true, "code": true, "pre": true}
func walkResolve(n *html.Node, r Resolver) {
if n.Type == html.ElementNode && skipTags[n.Data] {
// A pasted quarry URL is already an anchor by the time the
// resolver runs (GFM autolinks it), so the reference is marked
// on the link the author's own text produced rather than
// minted a second time beside it.
if n.Data == "a" {
annotateAnchor(n, r)
}
return
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walkResolve(c, r)
}
// Collect text children first: splicing replaces nodes mid-walk.
var texts []*html.Node
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
texts = append(texts, c)
}
}
for _, t := range texts {
splice(n, t, r)
}
}
// splice replaces one text node with text/link/text/... nodes for each
// resolvable reference found in it.
func splice(parent, text *html.Node, r Resolver) {
s := text.Data
type ref struct {
start, end int
label, url, desc string
token, kind string
hue int
isUser bool
// styled marks a reference the grammar CLAIMED, which is the
// only kind that gets class="ref". The older wiki and hash
// rules keep the look they have had since 0002 — the hooks are
// added to them, the styling is not changed.
styled bool
mark string // the mentioned account's identity glyph
ticket *TicketRef
landing *LandingRef
}
var refs []ref
var claimed []refSpan
if tr, ok := r.(TokenResolver); ok {
for _, sp := range scanRefs(s) {
sp.tok = forSurface(sp.tok, tr)
url, ok := tr.ResolveToken(sp.tok)
if !ok {
continue
}
claimed = append(claimed, sp)
rf := ref{start: sp.start, end: sp.end,
label: s[sp.start:sp.end], url: url, styled: true,
token: sp.tok.Canonical(), kind: sp.tok.Kind}
if td, ok := r.(TicketDescriber); ok && (sp.tok.Kind == RefTicket || sp.tok.Kind == RefHash) {
if tr, ok := td.DescribeTicket(sp.tok); ok {
rf.ticket, rf.url, rf.label = &tr, tr.URL, ticketLabel(rf.label, tr)
rf.token, rf.kind = ticketToken(sp.tok.Repo, tr), RefTicket
}
}
if ld, ok := r.(LandingDescriber); ok && sp.tok.Kind == RefLanding {
if lr, ok := ld.DescribeLanding(sp.tok); ok {
rf.landing, rf.label = &lr, numberLabel(rf.label)
}
}
refs = append(refs, rf)
}
}
// A claimed span is the author's whole token; the older rules must
// not re-link the hex sitting inside one.
overlaps := func(start, end int) bool {
for _, c := range claimed {
if start < c.end && c.start < end {
return true
}
}
return false
}
for _, m := range wikiRef.FindAllStringSubmatchIndex(s, -1) {
name := s[m[2]:m[3]]
if overlaps(m[0], m[1]) {
continue
}
if url := r.ResolveWiki(name); url != "" {
tok := RefToken{Kind: RefWiki, ID: name}
refs = append(refs, ref{start: m[0], end: m[1], label: name, url: url,
token: tok.Canonical(), kind: tok.Kind})
}
}
if ur, ok := r.(UserResolver); ok {
for _, m := range mentionRef.FindAllStringSubmatchIndex(s, -1) {
name := s[m[4]:m[5]]
if overlaps(m[3], m[1]) {
continue
}
if url, hue, ok := ur.ResolveUser(name); ok {
// The @ belongs to the link; the leading boundary char
// stays as text (start index m[3] ends the boundary).
// The label drops the @: the mark in front already says
// this is a person, and "@" twice over — glyph and
// sigil — is the syntax showing through the rendering.
// The source keeps it; the reader does not need it.
refs = append(refs, ref{start: m[3], end: m[1], label: name,
url: url, hue: hue, isUser: true, mark: r.UserMark(name)})
}
}
}
hd, _ := r.(HashDescriber)
td, _ := r.(TicketDescriber)
for _, m := range hashRef.FindAllStringIndex(s, -1) {
tok := s[m[0]:m[1]]
if overlaps(m[0], m[1]) {
continue
}
// A ticket first: it is the one hash kind that reads better as
// a number and a state, and a LOCAL ticket is not in the index
// at all, so ResolveHash would leave it as plain text.
if td != nil {
ref0 := RefToken{Kind: RefHash, ID: strings.ToLower(tok)}
if tr, ok := td.DescribeTicket(ref0); ok {
refs = append(refs, ref{start: m[0], end: m[1], label: ticketLabel(tok, tr), url: tr.URL,
token: ticketToken("", tr), kind: RefTicket, ticket: &tr})
continue
}
}
if url := r.ResolveHash(tok); url != "" {
ref0 := RefToken{Kind: RefHash, ID: strings.ToLower(tok)}
rf := ref{start: m[0], end: m[1], label: tok, url: url,
token: ref0.Canonical(), kind: ref0.Kind}
if hd != nil {
rf.desc = hd.DescribeHash(tok)
}
refs = append(refs, rf)
}
}
if len(refs) == 0 {
return
}
// Wiki and hash matches cannot overlap ([[..]] never lexes as a hex
// word), so a simple position sort orders the splice.
for i := 1; i < len(refs); i++ {
for j := i; j > 0 && refs[j-1].start > refs[j].start; j-- {
refs[j-1], refs[j] = refs[j], refs[j-1]
}
}
pos := 0
insert := func(nn *html.Node) { parent.InsertBefore(nn, text) }
for _, rf := range refs {
if rf.start > pos {
insert(&html.Node{Type: html.TextNode, Data: s[pos:rf.start]})
}
a := &html.Node{Type: html.ElementNode, Data: "a", DataAtom: atom.A,
Attr: []html.Attribute{{Key: "href", Val: rf.url}}}
if rf.kind == RefPerson {
// A mention is a CHIP: the mark that says this line
// addresses somebody. No preview hook — the person card is
// gated on a capability most readers do not hold, so
// arming it would offer nearly everybody an empty popover.
a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: "at"})
} else if rf.token != "" && !rf.isUser {
if rf.styled {
a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: "ref"})
}
a.Attr = append(a.Attr,
html.Attribute{Key: "data-ref", Val: rf.token},
html.Attribute{Key: "data-ref-kind", Val: rf.kind})
}
if rf.isUser {
a.Attr = append(a.Attr,
html.Attribute{Key: "class", Val: "uref at"},
html.Attribute{Key: "style", Val: fmt.Sprintf("--uh: %d", rf.hue)})
// The identity mark rides INSIDE the link, parsed into
// nodes rather than pasted as text — a text node would
// render as visible angle brackets.
//
// Resolution runs AFTER sanitizing (see the file header), so
// nothing re-inspects this. That is safe here and only here
// because the mark is quarry's own drawing of a name, not
// anything an author wrote: the document policy admits no
// <svg> at all, and this is not a hole in it but a glyph
// added on the far side of it, the same way the pikchr
// diagrams are restored after their own narrow policy.
if rf.mark != "" {
if frag, err := html.ParseFragment(strings.NewReader(rf.mark), a); err == nil {
for _, n := range frag {
a.AppendChild(n)
}
}
}
}
a.AppendChild(&html.Node{Type: html.TextNode, Data: rf.label})
// A described reference (a check-in's comment) renders GitHub's
// way: the message rides the link — full first line as the
// hover title, an excerpt visible beside the hash.
if rf.desc != "" {
a.Attr = append(a.Attr, html.Attribute{Key: "title", Val: rf.desc})
}
if rf.ticket != nil {
addClass(a, "tref")
ticketIcon(*rf.ticket, rf.label).wear(a)
}
if rf.landing != nil {
landingIcon(*rf.landing).wear(a)
}
insert(a)
if rf.ticket != nil {
for _, n := range ticketPills(*rf.ticket) {
insert(n)
}
}
if rf.desc != "" {
span := &html.Node{Type: html.ElementNode, Data: "span", DataAtom: atom.Span,
Attr: []html.Attribute{{Key: "class", Val: "cref"}}}
span.AppendChild(&html.Node{Type: html.TextNode, Data: " (" + excerpt(rf.desc, 60) + ")"})
insert(span)
}
pos = rf.end
}
if pos < len(s) {
insert(&html.Node{Type: html.TextNode, Data: s[pos:]})
}
parent.RemoveChild(text)
}
// WikiRef is one page reference found in a body.
type WikiRef struct {
Name string
// Explicit marks the [[Name]] construct, which names a page whether
// or not it exists — a reader wrote it meaning "there should be a
// page here". Fossil's [Name|label] form is not explicit: fossil
// links it only when the page already exists and otherwise leaves
// it as text, so an unresolvable one is a sentence, not a request.
Explicit bool
}
// WikiRefs lists the page references in a body, in order and without
// duplicates. It reads the SAME two constructs the renderer resolves,
// because a structure page built from a second, nearly-identical rule
// would eventually disagree with the links on the page it describes.
//
// External targets (anything with a scheme or a slash) are not page
// names and are left out.
func WikiRefs(body []byte, fossilWiki bool) []WikiRef {
s := string(body)
var out []WikiRef
seen := map[string]bool{}
keep := func(name string, explicit bool) {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
return
}
if strings.Contains(name, "/") || strings.Contains(name, ":") {
return // a URL, not a page
}
seen[name] = true
out = append(out, WikiRef{Name: name, Explicit: explicit})
}
for _, m := range wikiRef.FindAllStringSubmatch(s, -1) {
keep(m[1], true)
}
if fossilWiki {
// Fossil's [Target|label] pattern also matches the inner half of
// a [[Name]], which would report "[Name" as a page. Blank the
// double-bracket spans first so each construct is read once.
masked := wikiRef.ReplaceAllStringFunc(s, func(m string) string {
return strings.Repeat(" ", len(m))
})
for _, m := range fossilLink.FindAllStringSubmatch(masked, -1) {
keep(m[1], false)
}
}
return out
}
// annotateAnchor marks an EXISTING link whose href names a quarry
// artifact — the pasted-URL half of the reference grammar. Nothing is
// rewritten: the author's href and label stay exactly as written, and
// only the reference hooks are added.
func annotateAnchor(a *html.Node, r Resolver) {
annotateTicketAnchor(a, r)
tr, ok := r.(TokenResolver)
if !ok {
return
}
var href string
for _, at := range a.Attr {
if at.Key == "href" {
href = at.Val
}
if at.Key == "data-ref" {
return // already marked
}
}
tok, ok := ParseRef(href)
if !ok {
return
}
if _, ok := tr.ResolveToken(tok); !ok {
return
}
addClass(a, "ref")
a.Attr = append(a.Attr,
html.Attribute{Key: "data-ref", Val: tok.Canonical()},
html.Attribute{Key: "data-ref-kind", Val: tok.Kind})
}
// annotateTicketAnchor gives a link the AUTHOR wrote to a ticket's page
// the same number and state a typed reference gets. Its text is kept
// unless it is only the ticket's id, which reads better as #N.
func annotateTicketAnchor(a *html.Node, r Resolver) {
td, ok := r.(TicketDescriber)
if !ok || a.Parent == nil {
return
}
var href string
for _, at := range a.Attr {
switch at.Key {
case "href":
href = at.Val
case "class":
if strings.Contains(" "+at.Val+" ", " tref ") {
return // already described
}
}
}
tok, ok := ParseRef(href)
if !ok || tok.Kind != RefTicket {
return
}
tr, ok := td.DescribeTicket(tok)
if !ok {
return
}
addClass(a, "tref")
// The page's preview card, the same one a typed reference gets.
a.Attr = append(a.Attr,
html.Attribute{Key: "data-ref", Val: ticketToken(tok.Repo, tr)},
html.Attribute{Key: "data-ref-kind", Val: RefTicket})
if c := a.FirstChild; c != nil && c.NextSibling == nil && c.Type == html.TextNode {
c.Data = ticketLabel(strings.TrimSpace(c.Data), tr)
}
num := ""
if tr.Number > 0 {
num = fmt.Sprintf("%d", tr.Number)
}
ticketIcon(tr, num).wear(a)
next := a.NextSibling
for _, n := range ticketPills(tr) {
a.Parent.InsertBefore(n, next)
}
}
// addClass adds one class to an element, merging with any it has: two
// class attributes on one element keep only the first.
func addClass(n *html.Node, class string) {
for i, at := range n.Attr {
if at.Key == "class" {
n.Attr[i].Val = strings.TrimSpace(at.Val + " " + class)
return
}
}
n.Attr = append(n.Attr, html.Attribute{Key: "class", Val: class})
}