Files
datascape/wikilinks.go
T
2026-09-17 17:46:36 +02:00

144 lines
4.3 KiB
Go

package main
import (
"bytes"
"net/url"
"regexp"
"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"
)
// wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of
// the current inline reader. Query and display forbid newlines and brackets;
// the query is non-greedy so the first `::` separates query from display when
// both are present.
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
// normalized by wikiSearchQuery.
type wikiLinkNode struct {
ast.BaseInline
Query []byte
Display []byte
}
var kindWikiLink = ast.NewNodeKind("WikiLink")
func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink }
func (n *wikiLinkNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Query": string(n.Query),
"Display": string(n.Display),
}, nil)
}
// wikiSearchQuery normalizes the raw text inside [[...]] into a search query.
// A leading `/` marks a link written under the old path-based syntax; those
// collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so
// existing content keeps resolving without a rewrite. Everything else is free
// text and passes through with only surrounding space trimmed.
func wikiSearchQuery(raw string) string {
q := strings.TrimSpace(raw)
if !strings.HasPrefix(q, "/") {
return q
}
trimmed := strings.Trim(q, "/")
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
return strings.TrimSpace(trimmed[i+1:])
}
return trimmed
}
// wikiSearchHref builds the search-page URL for a query. Escaping leaves only
// URL-safe bytes, so the result needs no further HTML escaping in an href.
func wikiSearchHref(query string) string {
return "/?q=" + url.QueryEscape(query)
}
type wikiLinkParser struct{}
func (p *wikiLinkParser) Trigger() []byte { return []byte{'['} }
func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()
if len(line) < 4 || line[0] != '[' || line[1] != '[' {
return nil
}
m := wikiLinkRe.FindSubmatchIndex(line)
if m == nil {
return nil
}
// A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is
// not a link; leave the source text alone.
query := wikiSearchQuery(string(line[m[2]:m[3]]))
if query == "" {
return nil
}
var display []byte
if m[4] != -1 {
display = bytes.TrimSpace(line[m[4]:m[5]])
}
block.Advance(m[1])
return &wikiLinkNode{
Query: []byte(query),
Display: append([]byte(nil), display...),
}
}
type wikiLinkRenderer struct{}
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiLink, r.render)
}
func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*wikiLinkNode)
writeWikiSearchLink(w, string(n.Query), string(n.Display))
return ast.WalkContinue, nil
}
// writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
// search page, labelled with the display override or the query itself. Nothing
// is resolved at render time — whether the query matches anything is the search
// page's business, so there is no broken state here.
func writeWikiSearchLink(w util.BufWriter, query, display string) {
if display == "" {
display = query
}
w.WriteString(`<a href="`)
w.WriteString(wikiSearchHref(query))
w.WriteString(`">`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
}
type wikiLinkExt struct{}
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
// links to the search page. It needs no wiki root — a query is never resolved
// against the filesystem.
func newWikiLinkExt() goldmark.Extender {
return &wikiLinkExt{}
}
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
// Priority 199 — one higher than the default link parser (200) so
// [[...]] is consumed before the default parser sees the outer `[`.
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(&wikiLinkParser{}, 199),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiLinkRenderer{}, 500),
))
}