| 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("&", "&", "<", "<", ">", ">") |
| 203 |
|
var attrEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) |
| 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 |
} |