package doc
import (
"bytes"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// wikilink is an inline node for the [[target]], [[target|label]] and
// ![[target]] syntax the corpus is written in.
//
// It is a goldmark inline parser rather than a pre-pass regex for one reason
// that matters here: a spec store documents its own link syntax, so documents
// contain literal `[[doc]]` inside code spans and ![[transclusions]] inside
// fenced code blocks. goldmark hands inline parsers only the text that is not
// code, so those are left alone for free — a regex over the raw source would
// rewrite them and break the very documents explaining the syntax.
type wikilink struct {
ast.BaseInline
Dest string // link target as written, before resolution
Label string // display text; empty means "use the target"
Embed bool // written as ![[…]]
target Target // filled in by Render's walk, via the Resolver
}
var kindWikilink = ast.NewNodeKind("Wikilink")
func (n *wikilink) Kind() ast.NodeKind { return kindWikilink }
func (n *wikilink) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{"Dest": n.Dest, "Label": n.Label}, nil)
}
// wikilinkParser recognises [[…]] and ![[…]] at an opening bracket.
type wikilinkParser struct{}
func (wikilinkParser) Trigger() []byte { return []byte{'[', '!'} }
// maxWikilinkLen bounds how far the parser scans for a closing "]]". A target
// this long is prose that happens to open a bracket pair, not a link.
const maxWikilinkLen = 512
func (wikilinkParser) Parse(_ ast.Node, block text.Reader, _ parser.Context) ast.Node {
line, seg := block.PeekLine()
embed := false
open := 0
if len(line) > 0 && line[0] == '!' {
embed = true
open = 1
}
if len(line) < open+4 || line[open] != '[' || line[open+1] != '[' {
return nil
}
inner := line[open+2:]
if len(inner) > maxWikilinkLen {
inner = inner[:maxWikilinkLen]
}
end := bytes.Index(inner, []byte("]]"))
if end < 0 {
return nil
}
// A newline before the closer means the brackets are unrelated: a wikilink
// never spans lines.
if bytes.IndexByte(inner[:end], '\n') >= 0 {
return nil
}
body := string(inner[:end])
total := open + 2 + end + 2
dest, label, hasLabel := strings.Cut(body, "|")
// Inside a markdown table the alias pipe must be escaped — an author writes
// [[c3-lang\|C3]] there — and the backslash survives into the inline text.
// Without this the target reads as "c3-lang\" and the link dangles, which
// is common in comparison tables.
dest = strings.TrimSuffix(strings.TrimSpace(dest), `\`)
dest = strings.TrimSpace(dest)
if dest == "" {
return nil // "[[]]" or "[[|x]]" is not a link
}
if !hasLabel {
label = ""
}
block.Advance(total)
_ = seg
return &wikilink{Dest: dest, Label: strings.TrimSpace(label), Embed: embed}
}
// wikilinkRenderer writes a resolved wikilink as an anchor, an image, or — when
// nothing resolved — a visibly broken span carrying the .wikilink-missing class.
// Missing links are never silently dropped: the read plane doubles as a link
// checker, and a link the reader cannot see is one nobody fixes.
type wikilinkRenderer struct{}
func (wikilinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikilink, renderWikilink)
}
func renderWikilink(w util.BufWriter, _ []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkSkipChildren, nil
}
n := node.(*wikilink)
label := n.Label
if label == "" {
label = displayLabel(n.Dest)
}
switch {
case n.target.Missing:
_, _ = w.WriteString(`<span class="wikilink-missing" title="unresolved link: `)
_, _ = w.WriteString(escapeAttr(n.Dest))
_, _ = w.WriteString(`">`)
_, _ = w.WriteString(escapeText(label))
_, _ = w.WriteString(`</span>`)
case n.Embed && n.target.PageID == "" && !n.target.IsExternal:
// An embedded attachment: an image renders inline, anything else (a PDF,
// a format this package does not interpret) degrades to a labelled link
// rather than broken markup.
if isImageHref(n.target.Href) {
_, _ = w.WriteString(`<img class="wikilink-embed" src="`)
_, _ = w.WriteString(escapeAttr(n.target.Href))
_, _ = w.WriteString(`" alt="`)
_, _ = w.WriteString(escapeAttr(label))
_, _ = w.WriteString(`">`)
break
}
writeAnchor(w, n.target.Href, "wikilink wikilink-file", label)
case n.Embed:
// An embedded document. There is no transclusion: a document's content
// has one canonical home, and inlining it would duplicate it into the
// search index and the reading view alike.
writeAnchor(w, n.target.Href, "wikilink wikilink-embed-ref", label)
default:
class := "wikilink"
if n.target.IsExternal {
class = "wikilink wikilink-external"
}
writeAnchor(w, n.target.Href, class, label)
}
return ast.WalkSkipChildren, nil
}
func writeAnchor(w util.BufWriter, href, class, label string) {
_, _ = w.WriteString(`<a class="` + class + `" href="`)
_, _ = w.WriteString(escapeAttr(href))
_, _ = w.WriteString(`">`)
_, _ = w.WriteString(escapeText(label))
_, _ = w.WriteString(`</a>`)
}
// DisplayText is the text a wikilink contributes to the page's searchable plain
// text: its label, or the target when it has none.
func (n *wikilink) DisplayText() string {
if n.Label != "" {
return n.Label
}
return displayLabel(n.Dest)
}
// displayLabel is what an unlabelled [[wiki/lsm-tree#Compaction]] shows: the
// target's last path segment, without the fragment.
func displayLabel(dest string) string {
if i := strings.IndexByte(dest, '#'); i >= 0 {
if i == 0 {
return dest[1:] // a same-page [[#Heading]] reference
}
dest = dest[:i]
}
if i := strings.LastIndexByte(dest, '/'); i >= 0 {
dest = dest[i+1:]
}
return strings.TrimSuffix(dest, ".md")
}
var imageExts = []string{".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp"}
func isImageHref(href string) bool {
lower := strings.ToLower(href)
if i := strings.IndexAny(lower, "?#"); i >= 0 {
lower = lower[:i]
}
for _, ext := range imageExts {
if strings.HasSuffix(lower, ext) {
return true
}
}
return false
}
var textEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">")
var attrEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
func escapeText(s string) string { return textEscaper.Replace(s) }
func escapeAttr(s string) string { return attrEscaper.Replace(s) }
// wikilinkExtension wires the parser and renderer into goldmark. The parser
// priority sits above goldmark's own link parser (100) so "[[" is claimed before
// it is read as a link label containing a bracket.
type wikilinkExtension struct{}
func (wikilinkExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(wikilinkParser{}, 99),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(wikilinkRenderer{}, 99),
))
}