coverage~bigbes/sr-ht-spec64cae3afdoc/wikilink.go

Coverage
90.5% 76/84 statements
Δ
+0.0
Blob
92a86bd
1 package doc
2
3 import (
4 "bytes"
5 "strings"
6
7 "github.com/yuin/goldmark"
8 "github.com/yuin/goldmark/ast"
9 "github.com/yuin/goldmark/parser"
10 "github.com/yuin/goldmark/renderer"
11 "github.com/yuin/goldmark/text"
12 "github.com/yuin/goldmark/util"
13 )
14
15 // wikilink is an inline node for the [[target]], [[target|label]] and
16 // ![[target]] syntax the corpus is written in.
17 //
18 // It is a goldmark inline parser rather than a pre-pass regex for one reason
19 // that matters here: a spec store documents its own link syntax, so documents
20 // contain literal `[[doc]]` inside code spans and ![[transclusions]] inside
21 // fenced code blocks. goldmark hands inline parsers only the text that is not
22 // code, so those are left alone for free — a regex over the raw source would
23 // rewrite them and break the very documents explaining the syntax.
24 type wikilink struct {
25 ast.BaseInline
26 Dest string // link target as written, before resolution
27 Label string // display text; empty means "use the target"
28 Embed bool // written as ![[…]]
29
30 target Target // filled in by Render's walk, via the Resolver
31 }
32
33 var kindWikilink = ast.NewNodeKind("Wikilink")
34
35 54 func (n *wikilink) Kind() ast.NodeKind { return kindWikilink }
36
37 0 func (n *wikilink) Dump(source []byte, level int) {
38 0 ast.DumpHelper(n, source, level, map[string]string{"Dest": n.Dest, "Label": n.Label}, nil)
39 0 }
40
41 // wikilinkParser recognises [[…]] and ![[…]] at an opening bracket.
42 type wikilinkParser struct{}
43
44 22 func (wikilinkParser) Trigger() []byte { return []byte{'[', '!'} }
45
46 // maxWikilinkLen bounds how far the parser scans for a closing "]]". A target
47 // this long is prose that happens to open a bracket pair, not a link.
48 const maxWikilinkLen = 512
49
50 34 func (wikilinkParser) Parse(_ ast.Node, block text.Reader, _ parser.Context) ast.Node {
51 34 line, seg := block.PeekLine()
52 34
53 34 embed := false
54 34 open := 0
55 34 if len(line) > 0 && line[0] == '!' {
56 3 embed = true
57 3 open = 1
58 3 }
59 34 if len(line) < open+4 || line[open] != '[' || line[open+1] != '[' {
60 4 return nil
61 4 }
62 30 inner := line[open+2:]
63 30 if len(inner) > maxWikilinkLen {
64 0 inner = inner[:maxWikilinkLen]
65 0 }
66 30 end := bytes.Index(inner, []byte("]]"))
67 30 if end < 0 {
68 1 return nil
69 1 }
70 // A newline before the closer means the brackets are unrelated: a wikilink
71 // never spans lines.
72 29 if bytes.IndexByte(inner[:end], '\n') >= 0 {
73 0 return nil
74 0 }
75 29 body := string(inner[:end])
76 29 total := open + 2 + end + 2
77 29
78 29 dest, label, hasLabel := strings.Cut(body, "|")
79 29 // Inside a markdown table the alias pipe must be escaped — an author writes
80 29 // [[c3-lang\|C3]] there — and the backslash survives into the inline text.
81 29 // Without this the target reads as "c3-lang\" and the link dangles, which
82 29 // is common in comparison tables.
83 29 dest = strings.TrimSuffix(strings.TrimSpace(dest), `\`)
84 29 dest = strings.TrimSpace(dest)
85 29 if dest == "" {
86 2 return nil // "[[]]" or "[[|x]]" is not a link
87 2 }
88 27 if !hasLabel {
89 20 label = ""
90 20 }
91
92 27 block.Advance(total)
93 27 _ = seg
94 27 return &wikilink{Dest: dest, Label: strings.TrimSpace(label), Embed: embed}
95 }
96
97 // wikilinkRenderer writes a resolved wikilink as an anchor, an image, or — when
98 // nothing resolved — a visibly broken span carrying the .wikilink-missing class.
99 // Missing links are never silently dropped: the read plane doubles as a link
100 // checker, and a link the reader cannot see is one nobody fixes.
101 type wikilinkRenderer struct{}
102
103 22 func (wikilinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
104 22 reg.Register(kindWikilink, renderWikilink)
105 22 }
106
107 54 func renderWikilink(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
108 54 if !entering {
109 27 return ast.WalkSkipChildren, nil
110 27 }
111 27 n := node.(*wikilink)
112 27 label := n.Label
113 27 if label == "" {
114 20 label = displayLabel(n.Dest)
115 20 }
116
117 27 switch {
118 3 case n.target.Missing:
119 3 _, _ = w.WriteString(`<span class="wikilink-missing" title="unresolved link: `)
120 3 _, _ = w.WriteString(escapeAttr(n.Dest))
121 3 _, _ = w.WriteString(`">`)
122 3 _, _ = w.WriteString(escapeText(label))
123 3 _, _ = w.WriteString(`</span>`)
124
125 2 case n.Embed && n.target.PageID == "" && !n.target.IsExternal:
126 2 // An embedded attachment: an image renders inline, anything else (a PDF,
127 2 // a format this package does not interpret) degrades to a labelled link
128 2 // rather than broken markup.
129 2 if isImageHref(n.target.Href) {
130 1 _, _ = w.WriteString(`<img class="wikilink-embed" src="`)
131 1 _, _ = w.WriteString(escapeAttr(n.target.Href))
132 1 _, _ = w.WriteString(`" alt="`)
133 1 _, _ = w.WriteString(escapeAttr(label))
134 1 _, _ = w.WriteString(`">`)
135 1 break
136 }
137 1 writeAnchor(w, n.target.Href, "wikilink wikilink-file", label)
138
139 1 case n.Embed:
140 1 // An embedded document. There is no transclusion: a document's content
141 1 // has one canonical home, and inlining it would duplicate it into the
142 1 // search index and the reading view alike.
143 1 writeAnchor(w, n.target.Href, "wikilink wikilink-embed-ref", label)
144
145 21 default:
146 21 class := "wikilink"
147 21 if n.target.IsExternal {
148 0 class = "wikilink wikilink-external"
149 0 }
150 21 writeAnchor(w, n.target.Href, class, label)
151 }
152 27 return ast.WalkSkipChildren, nil
153 }
154
155 23 func writeAnchor(w util.BufWriter, href, class, label string) {
156 23 _, _ = w.WriteString(`<a class="` + class + `" href="`)
157 23 _, _ = w.WriteString(escapeAttr(href))
158 23 _, _ = w.WriteString(`">`)
159 23 _, _ = w.WriteString(escapeText(label))
160 23 _, _ = w.WriteString(`</a>`)
161 23 }
162
163 // DisplayText is the text a wikilink contributes to the page's searchable plain
164 // text: its label, or the target when it has none.
165 27 func (n *wikilink) DisplayText() string {
166 27 if n.Label != "" {
167 7 return n.Label
168 7 }
169 20 return displayLabel(n.Dest)
170 }
171
172 // displayLabel is what an unlabelled [[wiki/lsm-tree#Compaction]] shows: the
173 // target's last path segment, without the fragment.
174 40 func displayLabel(dest string) string {
175 40 if i := strings.IndexByte(dest, '#'); i >= 0 {
176 0 if i == 0 {
177 0 return dest[1:] // a same-page [[#Heading]] reference
178 0 }
179 0 dest = dest[:i]
180 }
181 40 if i := strings.LastIndexByte(dest, '/'); i >= 0 {
182 2 dest = dest[i+1:]
183 2 }
184 40 return strings.TrimSuffix(dest, ".md")
185 }
186
187 var imageExts = []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp"}
188
189 2 func isImageHref(href string) bool {
190 2 lower := strings.ToLower(href)
191 2 if i := strings.IndexAny(lower, "?#"); i >= 0 {
192 0 lower = lower[:i]
193 0 }
194 9 for _, ext := range imageExts {
195 9 if strings.HasSuffix(lower, ext) {
196 1 return true
197 1 }
198 }
199 1 return false
200 }
201
202 var textEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
203 var attrEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
204
205 26 func escapeText(s string) string { return textEscaper.Replace(s) }
206 28 func escapeAttr(s string) string { return attrEscaper.Replace(s) }
207
208 // wikilinkExtension wires the parser and renderer into goldmark. The parser
209 // priority sits above goldmark's own link parser (100) so "[[" is claimed before
210 // it is read as a link label containing a bracket.
211 type wikilinkExtension struct{}
212
213 23 func (wikilinkExtension) Extend(m goldmark.Markdown) {
214 23 m.Parser().AddOptions(parser.WithInlineParsers(
215 23 util.Prioritized(wikilinkParser{}, 99),
216 23 ))
217 23 m.Renderer().AddOptions(renderer.WithNodeRenderers(
218 23 util.Prioritized(wikilinkRenderer{}, 99),
219 23 ))
220 23 }