Wiki Links support

This commit is contained in:
2026-04-21 19:50:16 +02:00
parent 9639a70572
commit e0d2fb0b41
7 changed files with 478 additions and 5 deletions
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"bytes"
"net/url"
"os"
"path/filepath"
"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 [[target]] and [[target|display]] anchored at the start
// of the current inline reader. Target and display forbid newlines and
// brackets; target additionally forbids the pipe separator.
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\|\n]+)(?:\|([^\[\]\n]+))?\]\]`)
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
// Used by the move-endpoint rewriter; not by the goldmark parser.
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n\|]+)(\|[^\[\]\n]+)?\]\]`)
// wikiLinkNode is the AST node produced by wikiLinkParser.
type wikiLinkNode struct {
ast.BaseInline
Target []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{
"Target": string(n.Target),
"Display": string(n.Display),
}, nil)
}
// isValidWikiTarget rejects targets that are not absolute or that contain
// traversal / empty segments. Matches the validation used by the move endpoint.
func isValidWikiTarget(target []byte) bool {
if len(target) == 0 || target[0] != '/' {
return false
}
trimmed := strings.Trim(string(target), "/")
if trimmed == "" {
return true // root link
}
for _, seg := range strings.Split(trimmed, "/") {
if seg == "" || seg == "." || seg == ".." {
return false
}
}
return true
}
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
}
target := bytes.TrimSpace(line[m[2]:m[3]])
if !isValidWikiTarget(target) {
return nil
}
var display []byte
if m[4] != -1 {
display = bytes.TrimSpace(line[m[4]:m[5]])
}
block.Advance(m[1])
return &wikiLinkNode{
Target: append([]byte(nil), target...),
Display: append([]byte(nil), display...),
}
}
// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and
// returns the cleaned absolute path.
func normalizeWikiTarget(target string) string {
if target == "/" {
return "/"
}
return "/" + strings.Trim(target, "/")
}
// wikiTargetHref converts a wiki target to a URL href with each segment
// percent-encoded and a trailing slash appended.
func wikiTargetHref(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
var b strings.Builder
for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") {
b.WriteByte('/')
b.WriteString(url.PathEscape(seg))
}
b.WriteByte('/')
return b.String()
}
// wikiTargetExists reports whether the on-disk folder backing the target
// exists under root.
func wikiTargetExists(root, target string) bool {
target = normalizeWikiTarget(target)
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
info, err := os.Stat(fsPath)
return err == nil && info.IsDir()
}
// wikiDefaultDisplay returns the last segment of a target, or "/" for the root.
func wikiDefaultDisplay(target string) string {
target = normalizeWikiTarget(target)
if target == "/" {
return "/"
}
segs := strings.Split(strings.TrimPrefix(target, "/"), "/")
return segs[len(segs)-1]
}
type wikiLinkRenderer struct {
root string
}
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)
target := string(n.Target)
href := wikiTargetHref(target)
display := string(n.Display)
if display == "" {
display = wikiDefaultDisplay(target)
}
broken := !wikiTargetExists(r.root, target)
w.WriteString(`<a href="`)
w.WriteString(href)
w.WriteString(`"`)
if broken {
w.WriteString(` class="broken"`)
}
w.WriteString(`>`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
return ast.WalkContinue, nil
}
type wikiLinkExt struct{ root string }
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
// links resolved against root.
func newWikiLinkExt(root string) goldmark.Extender {
return &wikiLinkExt{root: root}
}
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{root: e.root}, 500),
))
}