e8c3e53685
This prevents interference with markdown tables
188 lines
5.1 KiB
Go
188 lines
5.1 KiB
Go
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; the target is non-greedy so the first `::` separates target from
|
|
// display when both are present.
|
|
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),
|
|
))
|
|
}
|