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(``) _, _ = w.WriteString(escapeText(label)) _, _ = w.WriteString(``) 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(``)
			_, _ = 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(``) _, _ = w.WriteString(escapeText(label)) _, _ = w.WriteString(``) } // 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), )) }