Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

links.gno

5.87 Kb · 210 lines
  1package wiki
  2
  3import (
  4	"strings"
  5
  6	"gno.land/p/nt/markdown/sanitize/v0"
  7)
  8
  9// Link is one wikilink occurrence in a body.
 10type Link struct {
 11	Target   string // target text as written, before ParseTitle
 12	Label    string // display text; "" means render the target
 13	Explicit bool   // written [[:Category:X]]: link to the category, do not join it
 14	Start    int    // byte offset of the opening delimiter
 15	End      int    // byte offset just past the closing delimiter
 16}
 17
 18// ScanLinks finds every [[target|label]] occurrence delimited by open and
 19// close.
 20//
 21// The delimiters are parameters because the same syntax has to be found twice
 22// with different bytes. Indexing reads the raw body, where a link is
 23// "[[X]]". Rendering reads the body after sanitize.BlockRich, where the very
 24// same link is "\[\[X\]\]" because the sanitizer escapes every "[". Rendering
 25// must sanitize first and rewrite second: a rewriter that ran first would
 26// hand the markdown links it just generated to the escaper, and every link on
 27// the wiki would render as literal text.
 28//
 29// A link whose inner text spans a newline or is empty is not a link. When a
 30// nearer opener appears inside the inner text, scanning restarts from it, so
 31// "[[a [[b]]" yields b rather than a mis-parsed a.
 32func ScanLinks(s, openTok, closeTok string) []Link {
 33	out := []Link{}
 34	i := 0
 35	for i < len(s) {
 36		a := strings.Index(s[i:], openTok)
 37		if a < 0 {
 38			break
 39		}
 40		a += i
 41		rest := a + len(openTok)
 42		b := strings.Index(s[rest:], closeTok)
 43		if b < 0 {
 44			break
 45		}
 46		b += rest
 47
 48		inner := s[rest:b]
 49		if n := strings.Index(inner, openTok); n >= 0 {
 50			i = rest + n
 51			continue
 52		}
 53		i = b + len(closeTok)
 54		if inner == "" || strings.Contains(inner, "\n") {
 55			continue
 56		}
 57
 58		l := Link{Start: a, End: i}
 59		if p := strings.Index(inner, "|"); p >= 0 {
 60			l.Target = strings.TrimSpace(inner[:p])
 61			l.Label = strings.TrimSpace(inner[p+1:])
 62		} else {
 63			l.Target = strings.TrimSpace(inner)
 64		}
 65		if strings.HasPrefix(l.Target, ":") {
 66			l.Explicit = true
 67			l.Target = strings.TrimSpace(l.Target[1:])
 68		}
 69		if l.Target == "" {
 70			continue
 71		}
 72		out = append(out, l)
 73	}
 74	return out
 75}
 76
 77// redirectTarget returns the canonical title a body redirects to, or "".
 78// The syntax is MediaWiki's: "#REDIRECT [[Target]]" on the first line.
 79func redirectTarget(body string) string {
 80	line := body
 81	if i := strings.Index(line, "\n"); i >= 0 {
 82		line = line[:i]
 83	}
 84	line = strings.TrimSpace(line)
 85	if len(line) < len("#REDIRECT") || !strings.EqualFold(line[:len("#REDIRECT")], "#REDIRECT") {
 86		return ""
 87	}
 88	links := ScanLinks(line, "[[", "]]")
 89	if len(links) == 0 {
 90		return ""
 91	}
 92	t, err := ParseTitle(links[0].Target)
 93	if err != nil {
 94		return ""
 95	}
 96	return t.String()
 97}
 98
 99// Ctx carries what rendering needs from the realm: where the realm lives, and
100// which titles exist. Its function fields are read during a single Render call
101// and never stored, so no closure is ever persisted.
102type Ctx struct {
103	Base   string            // realm path prefix, e.g. "/r/moul/x/wiki/v0"
104	Exists func(Title) bool // nil treats every title as existing
105}
106
107func (c Ctx) exists(t Title) bool {
108	if c.Exists == nil {
109		return true
110	}
111	return c.Exists(t)
112}
113
114// URL is the render path of a title under this realm.
115func (c Ctx) URL(t Title) string { return c.Sub(t, "") }
116
117// Sub is the render path of a sub-route of a title, e.g. "history".
118func (c Ctx) Sub(t Title, route string) string {
119	u := c.Base + ":" + t.Slug()
120	if route != "" {
121		u += "/" + route
122	}
123	return escapeURL(u)
124}
125
126// SpecialURL is the render path of a Special: page.
127func (c Ctx) SpecialURL(name, query string) string {
128	u := c.Base + ":Special:" + name
129	if query != "" {
130		u += "?" + query
131	}
132	return escapeURL(u)
133}
134
135// escapeURL percent-encodes the characters a title may contain that would
136// otherwise terminate a markdown link destination or split a path. Titles
137// admit "(", ")", " ", "'" and "," (see validTitleRune), and an unencoded ")"
138// ends the "(...)" of a markdown link at the first occurrence, which turns
139// [[Mercury (planet)]] into a broken link plus stray text.
140func escapeURL(u string) string {
141	r := strings.NewReplacer(
142		" ", "%20",
143		"(", "%28",
144		")", "%29",
145		"'", "%27",
146		",", "%2C",
147	)
148	return sanitize.URL(r.Replace(u))
149}
150
151// RewriteLinks turns the wikilinks of an already-sanitized body into markdown
152// links. Category declarations are removed from the flow: membership is shown
153// by the rendered footer, not inline, which is also what MediaWiki does.
154//
155// s MUST be the output of sanitize.BlockRich or sanitize.Block, and open/close
156// MUST be the escaped delimiters. Passing a raw body here would emit links
157// built from unsanitized bytes.
158func RewriteLinks(c Ctx, s string) string {
159	links := ScanLinks(s, `\[\[`, `\]\]`)
160	if len(links) == 0 {
161		return s
162	}
163	var out strings.Builder
164	prev := 0
165	for _, l := range links {
166		out.WriteString(s[prev:l.Start])
167		prev = l.End
168
169		t, err := ParseTitle(unescapeInline(l.Target))
170		if err != nil {
171			// Not a usable title: leave the sanitized text in place. It is
172			// already escaped, so it renders as the literal brackets the
173			// author typed.
174			out.WriteString(s[l.Start:l.End])
175			continue
176		}
177		if t.NS == NSCategory && !l.Explicit {
178			continue
179		}
180		label := l.Label
181		if label == "" {
182			label = t.String()
183		}
184		if c.exists(t) {
185			out.WriteString("[" + label + "](" + c.URL(t) + ")")
186		} else {
187			// A red link: the page does not exist yet. Point at the same
188			// path, which renders the "create this page" stub.
189			out.WriteString("[" + label + "](" + c.URL(t) + ") ⁺")
190		}
191	}
192	out.WriteString(s[prev:])
193	return out.String()
194}
195
196// unescapeInline undoes the backslash escaping the sanitizer applies inside a
197// link's inner text, so ParseTitle sees the title the author typed.
198func unescapeInline(s string) string {
199	if !strings.Contains(s, `\`) {
200		return s
201	}
202	var b strings.Builder
203	for i := 0; i < len(s); i++ {
204		if s[i] == '\\' && i+1 < len(s) {
205			i++
206		}
207		b.WriteByte(s[i])
208	}
209	return b.String()
210}