~bigbes/sr-ht-spec

ref: bb3d22db80100b831492f6806f09f8bad3e141be sr-ht-spec/doc/render.go -rw-r--r-- 7.9 KiB
bb3d22db — Eugene Blikh web: git.sr.ht-style dashboard and unified nav brand 11 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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
package doc

import (
	"bytes"
	"strings"

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

// Link rewriting happens on the goldmark AST rather than via regex so that
// destinations are split from titles correctly and — for the [[wikilink]]
// syntax — so that links inside code spans and fenced code blocks are never
// mistaken for real links. A spec describing this service's own link syntax is
// exactly the document a regex would corrupt.

// Target is the outcome of resolving a raw link destination found in a
// document.
type Target struct {
	Href   string // final href: a site path, or the destination unchanged when missing
	PageID string // non-empty when the link points at another document in the archive
	Path   string // the target's path in the tree, when it resolved to one
	Kind   string // the target document's PageKind when PageID is set
	// IsExternal marks http(s)/mailto and friends.
	IsExternal bool
	// Missing marks an internal reference that resolved to no document and no
	// attachment. The renderer emits it with a distinct CSS class rather than
	// dropping it, so the read plane doubles as a link checker.
	Missing bool
}

// Resolver maps a raw link destination (as written in a document located in
// directory fromDir, relative to the space root) to a Target.
type Resolver interface {
	Resolve(fromDir, dest string) Target
}

// Heading is one entry in a document's table of contents.
type Heading struct {
	Level int    `json:"level"`
	Text  string `json:"text"`
	ID    string `json:"id"`
}

// Result bundles everything produced from rendering one document.
type Result struct {
	HTML      string
	Headings  []Heading
	LinkedIDs []string // outbound document IDs, deduped, first-appearance order
	PlainText string
	WordCount int
	// Wikilinks counts every [[…]] and ![[…]] in the document, resolved or not.
	Wikilinks int
	// MissingWikilinks holds the targets of wikilinks that resolved to no
	// document, alias or attachment — in first-appearance order, deduped.
	// Counted separately from ordinary markdown links because the two mean
	// different things: a broken wikilink is a defect in the space's own link
	// graph, while a broken relative link is usually a pasted external path.
	MissingWikilinks []string
}

// Renderer is a reusable, concurrency-safe markdown renderer.
type Renderer struct {
	md goldmark.Markdown
}

// NewRenderer builds a Renderer configured for GitHub-flavoured markdown with
// automatic heading anchors and the [[wikilink]] syntax.
func NewRenderer() *Renderer {
	md := goldmark.New(
		goldmark.WithExtensions(
			extension.GFM, // tables, strikethrough, autolinks, task lists
			extension.DefinitionList,
			extension.Footnote,
			wikilinkExtension{},
		),
		goldmark.WithParserOptions(
			parser.WithAutoHeadingID(),
		),
		goldmark.WithRendererOptions(
			html.WithUnsafe(), // documents here are first-party and reviewed
			renderer.WithNodeRenderers(
				util.Prioritized(tableRenderer{}, tableRendererPriority),
			),
		),
	)
	return &Renderer{md: md}
}

// Render parses source, rewrites links relative to fromDir via res, and returns
// the HTML plus the extracted structure. source must already have its YAML
// frontmatter removed (see ParseFront); a leading "---" block would otherwise
// render as a thematic break followed by stray text.
func (r *Renderer) Render(source []byte, fromDir string, res Resolver) Result {
	reader := text.NewReader(source)
	doc := r.md.Parser().Parse(reader)

	var headings []Heading
	linked := make([]string, 0, 8)
	seen := make(map[string]struct{}, 8)
	wikilinks := 0
	var missing []string
	missingSeen := make(map[string]struct{})

	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		switch node := n.(type) {
		case *wikilink:
			wikilinks++
			node.target = res.Resolve(fromDir, node.Dest)
			if id := node.target.PageID; id != "" {
				if _, ok := seen[id]; !ok {
					seen[id] = struct{}{}
					linked = append(linked, id)
				}
			}
			if node.target.Missing {
				if _, ok := missingSeen[node.Dest]; !ok {
					missingSeen[node.Dest] = struct{}{}
					missing = append(missing, node.Dest)
				}
			}
		case *ast.Link:
			t := res.Resolve(fromDir, string(node.Destination))
			node.Destination = []byte(t.Href)
			if t.PageID != "" {
				if _, ok := seen[t.PageID]; !ok {
					seen[t.PageID] = struct{}{}
					linked = append(linked, t.PageID)
				}
			}
		case *ast.Image:
			t := res.Resolve(fromDir, string(node.Destination))
			node.Destination = []byte(t.Href)
		case *ast.Heading:
			id, _ := node.AttributeString("id")
			hid, _ := id.([]byte)
			headings = append(headings, Heading{
				Level: node.Level,
				Text:  string(nodeText(node, source)),
				ID:    string(hid),
			})
		}
		return ast.WalkContinue, nil
	})

	var buf bytes.Buffer
	_ = r.md.Renderer().Render(&buf, source, doc)

	plain := plainText(doc, source)
	return Result{
		HTML:             buf.String(),
		Headings:         headings,
		LinkedIDs:        linked,
		PlainText:        plain,
		WordCount:        len(strings.Fields(plain)),
		Wikilinks:        wikilinks,
		MissingWikilinks: missing,
	}
}

// RenderInline renders a one-line fragment — a frontmatter property value — and
// returns just its inline HTML, without the wrapping paragraph.
//
// Frontmatter carries real links: `parent: "[[storage-model]]"`, and often the
// only pointer a document has to an attachment. Emitted as escaped text those
// read as literal double brackets and the attachment is unreachable. Running
// them through the same renderer as the body resolves wikilinks, marks the
// unresolved ones, and autolinks bare URLs.
//
// A value spanning more than one block is returned as rendered, paragraphs and
// all; mangling one would be worse than an extra <p>.
func (r *Renderer) RenderInline(source []byte, fromDir string, res Resolver) string {
	h := strings.TrimSuffix(r.Render(source, fromDir, res).HTML, "\n")
	inner, ok := strings.CutPrefix(h, "<p>")
	if !ok {
		return h
	}
	inner, ok = strings.CutSuffix(inner, "</p>")
	if !ok || strings.Contains(inner, "<p>") {
		return h
	}
	return inner
}

// nodeText returns the concatenated text of a node's descendants.
func nodeText(n ast.Node, source []byte) []byte {
	var b bytes.Buffer
	_ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
		if !entering {
			return ast.WalkContinue, nil
		}
		if t, ok := c.(*ast.Text); ok {
			b.Write(t.Segment.Value(source))
		}
		return ast.WalkContinue, nil
	})
	return b.Bytes()
}

// plainText projects the document to searchable text: inline text with a
// newline after each block-level node, and code-block contents included
// verbatim.
func plainText(doc ast.Node, source []byte) string {
	var b strings.Builder
	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
		switch node := n.(type) {
		case *wikilink:
			// A wikilink holds no Text child, so without this its label — often
			// the only mention of a related concept in the document — would be
			// absent from the keyword index.
			if entering {
				b.WriteString(node.DisplayText())
				b.WriteByte(' ')
			}
		case *ast.Text:
			if entering {
				b.Write(node.Segment.Value(source))
				if node.SoftLineBreak() || node.HardLineBreak() {
					b.WriteByte('\n')
				}
			}
		case *ast.FencedCodeBlock, *ast.CodeBlock:
			if entering {
				lines := n.Lines()
				for i := 0; i < lines.Len(); i++ {
					seg := lines.At(i)
					b.Write(seg.Value(source))
				}
			}
		default:
			// After leaving a block-level node, emit a separator so words from
			// adjacent blocks don't run together in the search index.
			if !entering && node != nil && n.Type() == ast.TypeBlock {
				b.WriteByte('\n')
			}
		}
		return ast.WalkContinue, nil
	})
	return b.String()
}