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}