// refpop.go is the preview half of RFC 0011 §14. The resolver already
// minted the link; this file adds the enhancement — one shared popover,
// a per-kind card fragment, and the Datastar hooks that fetch it.
//
// Principle 3 first: every one of those hooks rides ON an anchor that
// already works. With no script the reference is a link to the page it
// names, which is the whole feature; the card is the part that saves a
// navigation, not the part that makes the reference readable.
package web
import (
"fmt"
"html/template"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
)
// refAnchor matches an opening <a> tag the resolver marked. Marked, not
// shaped: enhancement is opt-in by the marker markup put there, so an
// ordinary link that happens to point at a repository page is left
// exactly as its author wrote it.
var refAnchor = regexp.MustCompile(`<a ([^>]*\bdata-ref="([^"]*)"[^>]*)>`)
// jsString escapes a value for a JS single-quoted string inside an HTML
// double-quoted attribute — the same neutralisation syncURLAttr does,
// and for the same reason: nothing may close either quote.
func jsString(s string) string {
e := url.QueryEscape(s)
e = strings.ReplaceAll(e, "'", "%27")
e = strings.ReplaceAll(e, `"`, "%22")
return e
}
// refPreviewAttrs is the hover/focus/tap enhancement for one reference.
//
// Click is bound WITHOUT __prevent and cancels the navigation itself,
// only on a pointer that cannot hover. The modifier is unconditional:
// it would eat a desktop reader's ordinary click — and their
// middle-click and cmd-click with it — to serve a card they already get
// on hover. RFC 0011 §14 asks for the tap path, not for taking the
// click away from everyone.
func refPreviewAttrs(repo, token string) string {
get := "@get('/r/" + jsString(repo) + "/ref.frag?ref=" + jsString(token) + "')"
open := "try{document.getElementById('refpop').showPopover()}catch(e){}"
hover := "if(matchMedia('(hover: hover)').matches){" + get + ";" + open + "}"
tap := "if(!matchMedia('(hover: hover)').matches){evt.preventDefault();" + get + ";" + open + "}"
return fmt.Sprintf(` data-on:mouseenter__debounce.300ms="%s" data-on:focus="%s" data-on:click="%s"`,
template.HTMLEscapeString(hover), template.HTMLEscapeString(hover),
template.HTMLEscapeString(tap))
}
// enhanceRefs arms every reference in a rendered body. It runs in the
// WEB layer, not in markup, so a body rendered for the API carries the
// links without the page chrome that only a page can use.
func enhanceRefs(body, repo string) template.HTML {
return template.HTML(refAnchor.ReplaceAllStringFunc(body, func(m string) string {
sub := refAnchor.FindStringSubmatch(m)
return "<a " + sub[1] + refPreviewAttrs(repo, html2text(sub[2])) + ">"
}))
}
// html2text undoes attribute escaping so the token reaches the URL as
// its author's own bytes.
func html2text(s string) string {
r := strings.NewReplacer("&", "&", """, `"`, """, `"`,
"'", "'", "<", "<", ">", ">")
return r.Replace(s)
}
// refCard is ref.resolve's answer, as the page reads it.
type refCard struct {
Kind string `json:"kind"`
ID string `json:"id"`
Repo string `json:"repo"`
Title string `json:"title"`
Summary string `json:"summary"`
State string `json:"state"`
Href string `json:"href"`
Expired bool `json:"expired"`
Author string `json:"author"`
At string `json:"at"`
}
// refCardHTML draws one card: what it is, one to three facts, and the
// way in. Every kind uses the same three slots rather than seven
// bespoke layouts — a reader glancing at a preview is reading a shape,
// not a form, and seven shapes is six too many.
func refCardHTML(c refCard) template.HTML {
var b strings.Builder
fmt.Fprintf(&b, `<div class="refcard" data-kind="%s"`, template.HTMLEscapeString(c.Kind))
if c.Expired {
b.WriteString(` data-expired`)
}
if c.State != "" {
fmt.Fprintf(&b, ` data-state="%s"`, template.HTMLEscapeString(c.State))
}
b.WriteString(">")
if c.Kind == "person" {
b.WriteString(string(identicon(c.Title)))
}
if c.Title != "" {
fmt.Fprintf(&b, `<b class="refcard-title">%s</b>`, template.HTMLEscapeString(c.Title))
}
if c.State != "" {
fmt.Fprintf(&b, ` <span class="refcard-state">%s</span>`, template.HTMLEscapeString(c.State))
}
if c.Summary != "" {
fmt.Fprintf(&b, `<br><span class="refcard-sum">%s</span>`, template.HTMLEscapeString(c.Summary))
}
if c.Author != "" || c.At != "" {
b.WriteString(`<br><small class="tl-meta-inline">`)
if c.Author != "" {
b.WriteString(template.HTMLEscapeString(c.Author))
}
if c.At != "" {
b.WriteString(" " + string(ago(c.At)))
}
b.WriteString(`</small>`)
}
if c.Expired {
b.WriteString(`<br><span class="refcard-sum">aged out of the chat window</span>`)
}
fmt.Fprintf(&b, `<br><a class="refcard-open" href="%s">open</a></div>`,
template.HTMLEscapeString(c.Href))
return template.HTML(b.String())
}
// refPopHTML wraps a card as the shared popover's whole content: the
// fragment REPLACES #refpop, so the popover attribute has to come back
// with it or the element stops being a popover after the first preview.
func refPopHTML(inner template.HTML) string {
return `<div id="refpop" popover>` + string(inner) + `</div>`
}
// chatQuoteHTML is the reply-quote block: who is being answered and the
// first line of what they said, above the reply's own text. It is a
// link to the permalink and carries the same preview hooks a reference
// in prose does, because it IS one.
func chatQuoteHTML(m ChatMessage, repo string) template.HTML {
if m.Ref == 0 || m.RefUser == "" {
return ""
}
href := "/r/" + url.PathEscape(repo) + "/chat/" + strconv.FormatUint(m.Ref, 10)
return template.HTML(fmt.Sprintf(
`<a class="chatquote" data-quote-seq="%d" href="%s"%s><b>%s</b> %s</a><br>`,
m.Ref, href, refPreviewAttrs(repo, "#"+strconv.FormatUint(m.Ref, 10)),
template.HTMLEscapeString(m.RefUser), template.HTMLEscapeString(m.RefLine)))
}
// patchElements writes one Datastar element patch — the same two-line
// SSE frame the caps preview fragment writes, spelled once here so the
// reference fragments and it cannot drift.
func patchElements(w http.ResponseWriter, body string) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "event: datastar-patch-elements\n")
for _, line := range strings.Split(body, "\n") {
fmt.Fprintf(w, "data: elements %s\n", line)
}
fmt.Fprint(w, "\n")
}
// trailingMention reads the @handle a composer is part-way through
// typing: the token at the very end of the text, and nothing else. A
// mention finished three words ago is not what the reader is asking to
// complete.
func trailingMention(text string) (string, bool) {
i := strings.LastIndexAny(text, " \t\n")
last := text[i+1:]
if !strings.HasPrefix(last, "@") {
return "", false
}
q := last[1:]
for _, c := range q {
if !isHandleRune(c) {
return "", false
}
}
return q, true
}
func isHandleRune(c rune) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
c == '.' || c == '_' || c == '-'
}
// chatRefsRow is ref-counting's page half: how often the CURRENT chat
// window named this artifact, and the latest message to land on.
type chatRefsRow struct {
Count int `json:"count"`
Seq uint64 `json:"seq"`
At string `json:"at"`
}
// chatRefsHTML draws the "chat" row that sits beside an artifact's
// durable MentionRef rows. It is visibly its own row and says it is
// window-bounded, because a count that silently shrinks reads as data
// loss to anyone who thought it was an index fact (RFC 0011 §14).
func chatRefsHTML(c chatRefsRow, repo string) template.HTML {
if c.Count == 0 || c.Seq == 0 {
return ""
}
href := "/r/" + url.PathEscape(repo) + "/chat/" + strconv.FormatUint(c.Seq, 10)
var b strings.Builder
fmt.Fprintf(&b, `<p class="chatrefs" data-chat-refs data-count="%d" data-seq="%d">`, c.Count, c.Seq)
fmt.Fprintf(&b, `<a href="%s"%s>%d chat %s</a>`, href,
refPreviewAttrs(repo, "#"+strconv.FormatUint(c.Seq, 10)),
c.Count, map[bool]string{true: "message", false: "messages"}[c.Count == 1])
if c.At != "" {
b.WriteString(` <small class="tl-meta-inline">last ` + string(ago(c.At)) + `</small>`)
}
b.WriteString(` <small class="tl-meta-inline">· within the chat window</small></p>`)
return template.HTML(b.String())
}
// chatRefsOf lifts the chat row out of any op answer that carries one,
// so four page handlers share one spelling of "did chat mention this".
func chatRefsOf(out any) chatRefsRow {
var v struct {
ChatRefs chatRefsRow `json:"chatRefs"`
}
remarshal(out, &v)
return v.ChatRefs
}