package markup
import (
"fmt"
"regexp"
"strings"
"testing"
)
// refHookAttrs matches the attributes a reference anchor carries beyond
// a plain link, so a size assertion can weigh one against the other.
var refHookAttrs = regexp.MustCompile(` (?:class="ref"|data-ref="[^"]*"|data-ref-kind="[^"]*")`)
// hostResolver is a resolver that resolves the reference grammar as
// well as the older wiki and hash rules — every surface's shapes in one
// render (RFC 0011 §14).
type hostResolver struct{ fakeResolver }
func (hostResolver) ResolveToken(t RefToken) (string, bool) {
if t.Kind == RefTicket {
return "/r/demo/ticket/" + t.ID, true
}
return "", false
}
// Every reference on every surface carries the same hook — the
// unqualified [[wiki]] and bare-hash spellings included. A reader
// hovering a hash in a ticket and a repo-qualified token in chat is
// doing the same thing and must get the same card.
func TestEveryReferenceCarriesThePreviewHook(t *testing.T) {
src := "See [[Design]], abc123def456 and /r/demo/ticket/xy9deadbeef too\n"
out, err := Render([]byte(src), hostResolver{})
if err != nil {
t.Fatal(err)
}
s := string(out)
for _, want := range []string{
`data-ref="wiki/Design"`,
`data-ref-kind="wiki"`,
`data-ref="[abc123def456]"`,
`data-ref-kind="hash"`,
`data-ref="demo:ticket/xy9deadbeef"`,
} {
if !strings.Contains(s, want) {
t.Fatalf("missing hook %s in:\n%s", want, s)
}
}
// The token, and nothing else. The behaviour is armed once for the
// whole page by delegated handlers on the layout's container, so an
// anchor that carried three copies of a 380-character expression
// now carries none.
if strings.Contains(s, "data-on:") {
t.Fatalf("an anchor carries its own handlers:\n%s", s)
}
// The hash link keeps the styling it has had since 0002: the hook
// is added, the look is not changed.
if strings.Contains(s, `class="ref"`) && !strings.Contains(s, `class="ref" data-ref="demo:ticket/xy9deadbeef"`) {
t.Fatalf("hash link restyled:\n%s", s)
}
}
// Delegation, not repetition: what one reference costs is what two
// hundred cost each. Before this, every anchor carried three copies of
// the same ~380-byte expression and a 200-reference chat page shipped
// roughly 200 KB of duplicated script — Principle 4 read backwards.
func TestReferenceCostDoesNotGrowWithTheExpression(t *testing.T) {
render := func(n int) string {
var src strings.Builder
for i := 1; i <= n; i++ {
fmt.Fprintf(&src, "line %d sees [[Design]] here\n\n", i)
}
out, err := Render([]byte(src.String()), fakeResolver{})
if err != nil {
t.Fatal(err)
}
return string(out)
}
one, many := render(1), render(200)
if strings.Contains(many, "data-on:") {
t.Fatalf("references still arm themselves one by one")
}
// Each further reference costs its two data hooks and its own
// prose, and nothing else. A per-anchor expression would put a
// kilobyte here.
per := float64(len(many)-len(one)) / 199
if per > 128 {
t.Fatalf("each reference costs %.0f bytes; delegation was meant to make it the hooks alone", per)
}
// Stripping the hooks leaves the plain links the feature rides on:
// the anchor is the whole navigation, with or without script.
plain := refHookAttrs.ReplaceAllString(many, "")
if n := strings.Count(plain, `<a href="/wiki/Design">Design</a>`); n != 200 {
t.Fatalf("%d of 200 references are plain navigable links", n)
}
}
// A resolver that resolves nothing mints no anchors, so there is
// nothing for the page's delegated handlers to reach.
func TestUnresolvedTokensCarryNoHook(t *testing.T) {
out, err := Render([]byte("See [[Nowhere]].\n"), fakeResolver{})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "data-ref") {
t.Fatalf("hooks minted for an unresolvable reference:\n%s", out)
}
}
// dualResolver resolves the same check-in through every seam a body can
// reach it by: the older bare-hash rule, the fossil dialect's own
// [target] links, and the reference grammar.
type dualResolver struct{ fakeResolver }
func (dualResolver) ResolveHash(prefix string) string { return "/r/demo/checkin/" + prefix }
func (dualResolver) ResolveToken(t RefToken) (string, bool) {
switch t.Kind {
case RefHash, RefCheckin:
return "/r/demo/checkin/" + t.ID, true
}
return "", false
}
// A bracketed hash long enough for the older rule is a reference like
// any other, in either dialect. The grammar claims the SHORT bracketed
// form only — claiming the long one would restyle every hash in the
// corpus and drop the comment the older rule draws beside it — so the
// consistency that matters is the hook, not the drawing: whichever rule
// mints the anchor, the page's delegated handlers must find a data-ref
// on it.
func TestLongBracketedHashesCarryTheHookInBothDialects(t *testing.T) {
const src = "see [abc1234567] here"
md, err := Render([]byte(src), dualResolver{})
if err != nil {
t.Fatal(err)
}
fossil, err := RenderFossilWiki([]byte(src), dualResolver{})
if err != nil {
t.Fatal(err)
}
for _, tt := range []struct{ dialect, got string }{
{"markdown", string(md)},
{"fossil", string(fossil)},
} {
if !strings.Contains(tt.got, `href="/r/demo/checkin/abc1234567"`) {
t.Fatalf("%s: the hash did not link:\n%s", tt.dialect, tt.got)
}
if !refMarked.MatchString(tt.got) {
t.Fatalf("%s: the anchor carries no data-ref, so nothing arms it:\n%s", tt.dialect, tt.got)
}
if strings.Contains(tt.got, "data-on:") {
t.Fatalf("%s: the anchor arms itself:\n%s", tt.dialect, tt.got)
}
}
// The short bracketed form is the one the grammar claims, and it is
// the only one that gains class="ref". Long ones keep the look the
// older rule has given them since 0002.
if strings.Contains(string(md), `class="ref"`) {
t.Fatalf("a long bracketed hash was restyled:\n%s", md)
}
}
// refMarked finds an opening <a> tag carrying a reference hook.
var refMarked = regexp.MustCompile(`<a [^>]*\bdata-ref="[^"]*"`)