Rework wikilinks feature
This commit is contained in:
+48
-116
@@ -3,8 +3,6 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -16,20 +14,17 @@ import (
|
||||
"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.
|
||||
// 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]+))?\]\]`)
|
||||
|
||||
// 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.
|
||||
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
|
||||
// normalized by wikiSearchQuery.
|
||||
type wikiLinkNode struct {
|
||||
ast.BaseInline
|
||||
Target []byte
|
||||
Query []byte
|
||||
Display []byte
|
||||
}
|
||||
|
||||
@@ -39,27 +34,32 @@ 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),
|
||||
"Query": string(n.Query),
|
||||
"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
|
||||
// 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(string(target), "/")
|
||||
if trimmed == "" {
|
||||
return true // root link
|
||||
trimmed := strings.Trim(q, "/")
|
||||
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
|
||||
return strings.TrimSpace(trimmed[i+1:])
|
||||
}
|
||||
for _, seg := range strings.Split(trimmed, "/") {
|
||||
if seg == "" || seg == "." || seg == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
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{}
|
||||
@@ -75,8 +75,10 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
target := bytes.TrimSpace(line[m[2]:m[3]])
|
||||
if !isValidWikiTarget(target) {
|
||||
// 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
|
||||
@@ -85,76 +87,12 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
|
||||
}
|
||||
block.Advance(m[1])
|
||||
return &wikiLinkNode{
|
||||
Target: append([]byte(nil), target...),
|
||||
Query: []byte(query),
|
||||
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()
|
||||
}
|
||||
|
||||
// wikiFileHref converts a wiki target to a URL href for the raw file: each
|
||||
// segment is percent-encoded and, unlike wikiTargetHref, no trailing slash is
|
||||
// appended. Used for image-embed click-through so the href points straight at
|
||||
// the file (companion click-interception skips anything ending in "/").
|
||||
func wikiFileHref(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))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// wikiTargetExists reports whether the on-disk path backing the target exists
|
||||
// under root. Any existing path — file or folder — counts as resolved; only a
|
||||
// missing path is treated as broken.
|
||||
func wikiTargetExists(root, target string) bool {
|
||||
target = normalizeWikiTarget(target)
|
||||
fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
|
||||
_, err := os.Stat(fsPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
type wikiLinkRenderer struct{}
|
||||
|
||||
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(kindWikiLink, r.render)
|
||||
@@ -165,38 +103,32 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*wikiLinkNode)
|
||||
writeWikiLink(w, r.root, string(n.Target), string(n.Display))
|
||||
writeWikiSearchLink(w, string(n.Query), string(n.Display))
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// writeWikiLink renders a wiki-link anchor for target with an optional display
|
||||
// override. An empty display falls back to the target's last segment; a target
|
||||
// that does not resolve on disk gets the `broken` class. Shared by the wiki-link
|
||||
// renderer and the embed renderer's link fallback.
|
||||
func writeWikiLink(w util.BufWriter, root, target, display string) {
|
||||
href := wikiTargetHref(target)
|
||||
// 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 = wikiDefaultDisplay(target)
|
||||
display = query
|
||||
}
|
||||
broken := !wikiTargetExists(root, target)
|
||||
|
||||
w.WriteString(`<a href="`)
|
||||
w.WriteString(href)
|
||||
w.WriteString(`"`)
|
||||
if broken {
|
||||
w.WriteString(` class="broken"`)
|
||||
}
|
||||
w.WriteString(`>`)
|
||||
w.WriteString(wikiSearchHref(query))
|
||||
w.WriteString(`">`)
|
||||
w.Write(util.EscapeHTML([]byte(display)))
|
||||
w.WriteString(`</a>`)
|
||||
}
|
||||
|
||||
type wikiLinkExt struct{ root string }
|
||||
type wikiLinkExt struct{}
|
||||
|
||||
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
|
||||
// links resolved against root.
|
||||
func newWikiLinkExt(root string) goldmark.Extender {
|
||||
return &wikiLinkExt{root: root}
|
||||
// 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) {
|
||||
@@ -206,6 +138,6 @@ func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
||||
util.Prioritized(&wikiLinkParser{}, 199),
|
||||
))
|
||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(&wikiLinkRenderer{root: e.root}, 500),
|
||||
util.Prioritized(&wikiLinkRenderer{}, 500),
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user