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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

links.gno

5.88 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/nym-moultest123/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}