coverage~bigbes/sr-ht-spec3cb1c03ddoc/render.go

Coverage
93.3% 70/75 statements
Δ
Blob
ccef2db
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/extension"
10 "github.com/yuin/goldmark/parser"
11 "github.com/yuin/goldmark/renderer"
12 "github.com/yuin/goldmark/renderer/html"
13 "github.com/yuin/goldmark/text"
14 "github.com/yuin/goldmark/util"
15 )
16
17 // Link rewriting happens on the goldmark AST rather than via regex so that
18 // destinations are split from titles correctly and — for the [[wikilink]]
19 // syntax — so that links inside code spans and fenced code blocks are never
20 // mistaken for real links. A spec describing this service's own link syntax is
21 // exactly the document a regex would corrupt.
22
23 // Target is the outcome of resolving a raw link destination found in a
24 // document.
25 type Target struct {
26 Href string // final href: a site path, or the destination unchanged when missing
27 PageID string // non-empty when the link points at another document in the archive
28 Path string // the target's path in the tree, when it resolved to one
29 Kind string // the target document's PageKind when PageID is set
30 // IsExternal marks http(s)/mailto and friends.
31 IsExternal bool
32 // Missing marks an internal reference that resolved to no document and no
33 // attachment. The renderer emits it with a distinct CSS class rather than
34 // dropping it, so the read plane doubles as a link checker.
35 Missing bool
36 }
37
38 // Resolver maps a raw link destination (as written in a document located in
39 // directory fromDir, relative to the space root) to a Target.
40 type Resolver interface {
41 Resolve(fromDir, dest string) Target
42 }
43
44 // Heading is one entry in a document's table of contents.
45 type Heading struct {
46 Level int `json:"level"`
47 Text string `json:"text"`
48 ID string `json:"id"`
49 }
50
51 // Result bundles everything produced from rendering one document.
52 type Result struct {
53 HTML string
54 Headings []Heading
55 LinkedIDs []string // outbound document IDs, deduped, first-appearance order
56 PlainText string
57 WordCount int
58 // Wikilinks counts every [[…]] and ![[…]] in the document, resolved or not.
59 Wikilinks int
60 // MissingWikilinks holds the targets of wikilinks that resolved to no
61 // document, alias or attachment — in first-appearance order, deduped.
62 // Counted separately from ordinary markdown links because the two mean
63 // different things: a broken wikilink is a defect in the space's own link
64 // graph, while a broken relative link is usually a pasted external path.
65 MissingWikilinks []string
66 }
67
68 // Renderer is a reusable, concurrency-safe markdown renderer.
69 type Renderer struct {
70 md goldmark.Markdown
71 }
72
73 // NewRenderer builds a Renderer configured for GitHub-flavoured markdown with
74 // automatic heading anchors and the [[wikilink]] syntax.
75 23 func NewRenderer() *Renderer {
76 23 md := goldmark.New(
77 23 goldmark.WithExtensions(
78 23 extension.GFM, // tables, strikethrough, autolinks, task lists
79 23 extension.DefinitionList,
80 23 extension.Footnote,
81 23 wikilinkExtension{},
82 23 ),
83 23 goldmark.WithParserOptions(
84 23 parser.WithAutoHeadingID(),
85 23 ),
86 23 goldmark.WithRendererOptions(
87 23 html.WithUnsafe(), // documents here are first-party and reviewed
88 23 renderer.WithNodeRenderers(
89 23 util.Prioritized(tableRenderer{}, tableRendererPriority),
90 23 ),
91 23 ),
92 23 )
93 23 return &Renderer{md: md}
94 23 }
95
96 // Render parses source, rewrites links relative to fromDir via res, and returns
97 // the HTML plus the extracted structure. source must already have its YAML
98 // frontmatter removed (see ParseFront); a leading "---" block would otherwise
99 // render as a thematic break followed by stray text.
100 29 func (r *Renderer) Render(source []byte, fromDir string, res Resolver) Result {
101 29 reader := text.NewReader(source)
102 29 doc := r.md.Parser().Parse(reader)
103 29
104 29 var headings []Heading
105 29 linked := make([]string, 0, 8)
106 29 seen := make(map[string]struct{}, 8)
107 29 wikilinks := 0
108 29 var missing []string
109 29 missingSeen := make(map[string]struct{})
110 29
111 322 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
112 322 if !entering {
113 161 return ast.WalkContinue, nil
114 161 }
115 161 switch node := n.(type) {
116 27 case *wikilink:
117 27 wikilinks++
118 27 node.target = res.Resolve(fromDir, node.Dest)
119 27 if id := node.target.PageID; id != "" {
120 21 if _, ok := seen[id]; !ok {
121 18 seen[id] = struct{}{}
122 18 linked = append(linked, id)
123 18 }
124 }
125 27 if node.target.Missing {
126 3 if _, ok := missingSeen[node.Dest]; !ok {
127 3 missingSeen[node.Dest] = struct{}{}
128 3 missing = append(missing, node.Dest)
129 3 }
130 }
131 1 case *ast.Link:
132 1 t := res.Resolve(fromDir, string(node.Destination))
133 1 node.Destination = []byte(t.Href)
134 1 if t.PageID != "" {
135 1 if _, ok := seen[t.PageID]; !ok {
136 1 seen[t.PageID] = struct{}{}
137 1 linked = append(linked, t.PageID)
138 1 }
139 }
140 0 case *ast.Image:
141 0 t := res.Resolve(fromDir, string(node.Destination))
142 0 node.Destination = []byte(t.Href)
143 2 case *ast.Heading:
144 2 id, _ := node.AttributeString("id")
145 2 hid, _ := id.([]byte)
146 2 headings = append(headings, Heading{
147 2 Level: node.Level,
148 2 Text: string(nodeText(node, source)),
149 2 ID: string(hid),
150 2 })
151 }
152 161 return ast.WalkContinue, nil
153 })
154
155 29 var buf bytes.Buffer
156 29 _ = r.md.Renderer().Render(&buf, source, doc)
157 29
158 29 plain := plainText(doc, source)
159 29 return Result{
160 29 HTML: buf.String(),
161 29 Headings: headings,
162 29 LinkedIDs: linked,
163 29 PlainText: plain,
164 29 WordCount: len(strings.Fields(plain)),
165 29 Wikilinks: wikilinks,
166 29 MissingWikilinks: missing,
167 29 }
168 }
169
170 // RenderInline renders a one-line fragment — a frontmatter property value — and
171 // returns just its inline HTML, without the wrapping paragraph.
172 //
173 // Frontmatter carries real links: `parent: "[[storage-model]]"`, and often the
174 // only pointer a document has to an attachment. Emitted as escaped text those
175 // read as literal double brackets and the attachment is unreachable. Running
176 // them through the same renderer as the body resolves wikilinks, marks the
177 // unresolved ones, and autolinks bare URLs.
178 //
179 // A value spanning more than one block is returned as rendered, paragraphs and
180 // all; mangling one would be worse than an extra <p>.
181 6 func (r *Renderer) RenderInline(source []byte, fromDir string, res Resolver) string {
182 6 h := strings.TrimSuffix(r.Render(source, fromDir, res).HTML, "\n")
183 6 inner, ok := strings.CutPrefix(h, "<p>")
184 6 if !ok {
185 0 return h
186 0 }
187 6 inner, ok = strings.CutSuffix(inner, "</p>")
188 6 if !ok || strings.Contains(inner, "<p>") {
189 0 return h
190 0 }
191 6 return inner
192 }
193
194 // nodeText returns the concatenated text of a node's descendants.
195 2 func nodeText(n ast.Node, source []byte) []byte {
196 2 var b bytes.Buffer
197 8 _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
198 8 if !entering {
199 4 return ast.WalkContinue, nil
200 4 }
201 4 if t, ok := c.(*ast.Text); ok {
202 2 b.Write(t.Segment.Value(source))
203 2 }
204 4 return ast.WalkContinue, nil
205 })
206 2 return b.Bytes()
207 }
208
209 // plainText projects the document to searchable text: inline text with a
210 // newline after each block-level node, and code-block contents included
211 // verbatim.
212 29 func plainText(doc ast.Node, source []byte) string {
213 29 var b strings.Builder
214 322 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
215 322 switch node := n.(type) {
216 54 case *wikilink:
217 54 // A wikilink holds no Text child, so without this its label — often
218 54 // the only mention of a related concept in the document — would be
219 54 // absent from the keyword index.
220 54 if entering {
221 27 b.WriteString(node.DisplayText())
222 27 b.WriteByte(' ')
223 27 }
224 100 case *ast.Text:
225 100 if entering {
226 50 b.Write(node.Segment.Value(source))
227 50 if node.SoftLineBreak() || node.HardLineBreak() {
228 0 b.WriteByte('\n')
229 0 }
230 }
231 4 case *ast.FencedCodeBlock, *ast.CodeBlock:
232 4 if entering {
233 2 lines := n.Lines()
234 2 for i := 0; i < lines.Len(); i++ {
235 2 seg := lines.At(i)
236 2 b.Write(seg.Value(source))
237 2 }
238 }
239 164 default:
240 164 // After leaving a block-level node, emit a separator so words from
241 164 // adjacent blocks don't run together in the search index.
242 164 if !entering && node != nil && n.Type() == ast.TypeBlock {
243 47 b.WriteByte('\n')
244 47 }
245 }
246 322 return ast.WalkContinue, nil
247 })
248 29 return b.String()
249 }