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(``) w.Write(util.EscapeHTML([]byte(display))) w.WriteString(``) 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), )) }