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