Wiki Links support
This commit is contained in:
+186
@@ -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),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user