| 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 |
|
} |