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

. 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, "

") if !ok { return h } inner, ok = strings.CutSuffix(inner, "

") if !ok || strings.Contains(inner, "

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