~bigbes/sr-ht-spec

ref: 8255ff90741113fd85e6f15706127bb5c30ca3f5 sr-ht-spec/doc/wikilink.go -rw-r--r-- 7.0 KiB
8255ff90 — Eugene Blikh ci: export the version instead of sed-ing a tracked APKBUILD 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
package doc

import (
	"bytes"
	"strings"

	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/parser"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/text"
	"github.com/yuin/goldmark/util"
)

// wikilink is an inline node for the [[target]], [[target|label]] and
// ![[target]] syntax the corpus is written in.
//
// It is a goldmark inline parser rather than a pre-pass regex for one reason
// that matters here: a spec store documents its own link syntax, so documents
// contain literal `[[doc]]` inside code spans and ![[transclusions]] inside
// fenced code blocks. goldmark hands inline parsers only the text that is not
// code, so those are left alone for free — a regex over the raw source would
// rewrite them and break the very documents explaining the syntax.
type wikilink struct {
	ast.BaseInline
	Dest  string // link target as written, before resolution
	Label string // display text; empty means "use the target"
	Embed bool   // written as ![[…]]

	target Target // filled in by Render's walk, via the Resolver
}

var kindWikilink = ast.NewNodeKind("Wikilink")

func (n *wikilink) Kind() ast.NodeKind { return kindWikilink }

func (n *wikilink) Dump(source []byte, level int) {
	ast.DumpHelper(n, source, level, map[string]string{"Dest": n.Dest, "Label": n.Label}, nil)
}

// wikilinkParser recognises [[…]] and ![[…]] at an opening bracket.
type wikilinkParser struct{}

func (wikilinkParser) Trigger() []byte { return []byte{'[', '!'} }

// maxWikilinkLen bounds how far the parser scans for a closing "]]". A target
// this long is prose that happens to open a bracket pair, not a link.
const maxWikilinkLen = 512

func (wikilinkParser) Parse(_ ast.Node, block text.Reader, _ parser.Context) ast.Node {
	line, seg := block.PeekLine()

	embed := false
	open := 0
	if len(line) > 0 && line[0] == '!' {
		embed = true
		open = 1
	}
	if len(line) < open+4 || line[open] != '[' || line[open+1] != '[' {
		return nil
	}
	inner := line[open+2:]
	if len(inner) > maxWikilinkLen {
		inner = inner[:maxWikilinkLen]
	}
	end := bytes.Index(inner, []byte("]]"))
	if end < 0 {
		return nil
	}
	// A newline before the closer means the brackets are unrelated: a wikilink
	// never spans lines.
	if bytes.IndexByte(inner[:end], '\n') >= 0 {
		return nil
	}
	body := string(inner[:end])
	total := open + 2 + end + 2

	dest, label, hasLabel := strings.Cut(body, "|")
	// Inside a markdown table the alias pipe must be escaped — an author writes
	// [[c3-lang\|C3]] there — and the backslash survives into the inline text.
	// Without this the target reads as "c3-lang\" and the link dangles, which
	// is common in comparison tables.
	dest = strings.TrimSuffix(strings.TrimSpace(dest), `\`)
	dest = strings.TrimSpace(dest)
	if dest == "" {
		return nil // "[[]]" or "[[|x]]" is not a link
	}
	if !hasLabel {
		label = ""
	}

	block.Advance(total)
	_ = seg
	return &wikilink{Dest: dest, Label: strings.TrimSpace(label), Embed: embed}
}

// wikilinkRenderer writes a resolved wikilink as an anchor, an image, or — when
// nothing resolved — a visibly broken span carrying the .wikilink-missing class.
// Missing links are never silently dropped: the read plane doubles as a link
// checker, and a link the reader cannot see is one nobody fixes.
type wikilinkRenderer struct{}

func (wikilinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	reg.Register(kindWikilink, renderWikilink)
}

func renderWikilink(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if !entering {
		return ast.WalkSkipChildren, nil
	}
	n := node.(*wikilink)
	label := n.Label
	if label == "" {
		label = displayLabel(n.Dest)
	}

	switch {
	case n.target.Missing:
		_, _ = w.WriteString(`<span class="wikilink-missing" title="unresolved link: `)
		_, _ = w.WriteString(escapeAttr(n.Dest))
		_, _ = w.WriteString(`">`)
		_, _ = w.WriteString(escapeText(label))
		_, _ = w.WriteString(`</span>`)

	case n.Embed && n.target.PageID == "" && !n.target.IsExternal:
		// An embedded attachment: an image renders inline, anything else (a PDF,
		// a format this package does not interpret) degrades to a labelled link
		// rather than broken markup.
		if isImageHref(n.target.Href) {
			_, _ = w.WriteString(`<img class="wikilink-embed" src="`)
			_, _ = w.WriteString(escapeAttr(n.target.Href))
			_, _ = w.WriteString(`" alt="`)
			_, _ = w.WriteString(escapeAttr(label))
			_, _ = w.WriteString(`">`)
			break
		}
		writeAnchor(w, n.target.Href, "wikilink wikilink-file", label)

	case n.Embed:
		// An embedded document. There is no transclusion: a document's content
		// has one canonical home, and inlining it would duplicate it into the
		// search index and the reading view alike.
		writeAnchor(w, n.target.Href, "wikilink wikilink-embed-ref", label)

	default:
		class := "wikilink"
		if n.target.IsExternal {
			class = "wikilink wikilink-external"
		}
		writeAnchor(w, n.target.Href, class, label)
	}
	return ast.WalkSkipChildren, nil
}

func writeAnchor(w util.BufWriter, href, class, label string) {
	_, _ = w.WriteString(`<a class="` + class + `" href="`)
	_, _ = w.WriteString(escapeAttr(href))
	_, _ = w.WriteString(`">`)
	_, _ = w.WriteString(escapeText(label))
	_, _ = w.WriteString(`</a>`)
}

// DisplayText is the text a wikilink contributes to the page's searchable plain
// text: its label, or the target when it has none.
func (n *wikilink) DisplayText() string {
	if n.Label != "" {
		return n.Label
	}
	return displayLabel(n.Dest)
}

// displayLabel is what an unlabelled [[wiki/lsm-tree#Compaction]] shows: the
// target's last path segment, without the fragment.
func displayLabel(dest string) string {
	if i := strings.IndexByte(dest, '#'); i >= 0 {
		if i == 0 {
			return dest[1:] // a same-page [[#Heading]] reference
		}
		dest = dest[:i]
	}
	if i := strings.LastIndexByte(dest, '/'); i >= 0 {
		dest = dest[i+1:]
	}
	return strings.TrimSuffix(dest, ".md")
}

var imageExts = []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp"}

func isImageHref(href string) bool {
	lower := strings.ToLower(href)
	if i := strings.IndexAny(lower, "?#"); i >= 0 {
		lower = lower[:i]
	}
	for _, ext := range imageExts {
		if strings.HasSuffix(lower, ext) {
			return true
		}
	}
	return false
}

var textEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
var attrEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")

func escapeText(s string) string { return textEscaper.Replace(s) }
func escapeAttr(s string) string { return attrEscaper.Replace(s) }

// wikilinkExtension wires the parser and renderer into goldmark. The parser
// priority sits above goldmark's own link parser (100) so "[[" is claimed before
// it is read as a link label containing a bracket.
type wikilinkExtension struct{}

func (wikilinkExtension) Extend(m goldmark.Markdown) {
	m.Parser().AddOptions(parser.WithInlineParsers(
		util.Prioritized(wikilinkParser{}, 99),
	))
	m.Renderer().AddOptions(renderer.WithNodeRenderers(
		util.Prioritized(wikilinkRenderer{}, 99),
	))
}