Wiki Links support
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
function movePage() {
|
||||||
|
const current = window.location.pathname;
|
||||||
|
const target = prompt('Move this page to (absolute path):', current);
|
||||||
|
if (target === null) return;
|
||||||
|
const clean = target.trim();
|
||||||
|
if (!clean || !clean.startsWith('/')) {
|
||||||
|
alert('Move target must be an absolute path starting with /');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = current + '?move=' + encodeURIComponent(clean);
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePage() {
|
||||||
|
const current = window.location.pathname;
|
||||||
|
if (!confirm('Delete ' + current + ' and everything inside it?')) return;
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.method = 'POST';
|
||||||
|
form.action = current + '?delete=1';
|
||||||
|
document.body.appendChild(form);
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<link rel="stylesheet" href="/_/style.css" />
|
<link rel="stylesheet" href="/_/style.css" />
|
||||||
<script src="/_/global-shortcuts.js"></script>
|
<script src="/_/global-shortcuts.js"></script>
|
||||||
|
<script src="/_/page-actions.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
@@ -24,6 +25,10 @@
|
|||||||
{{else if .CanEdit}}
|
{{else if .CanEdit}}
|
||||||
<button class="btn" onclick="newPage()" title="New page (N)">NEW</button>
|
<button class="btn" onclick="newPage()" title="New page (N)">NEW</button>
|
||||||
<a class="btn" href="?edit" title="Edit page (E)">EDIT</a>
|
<a class="btn" href="?edit" title="Edit page (E)">EDIT</a>
|
||||||
|
{{if not .IsRoot}}
|
||||||
|
<button class="btn" onclick="movePage()" title="Move page">MOVE</button>
|
||||||
|
<button class="btn danger" onclick="deletePage()" title="Delete page">DELETE</button>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ a:hover {
|
|||||||
color: var(--link-hover);
|
color: var(--link-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Broken wiki link: target folder does not exist */
|
||||||
|
.content a.broken {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.content a.broken:hover {
|
||||||
|
color: var(--link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* === Header === */
|
/* === Header === */
|
||||||
header {
|
header {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
@@ -124,6 +133,14 @@ header {
|
|||||||
padding: 0 0.15rem;
|
padding: 0 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Destructive action */
|
||||||
|
.danger {
|
||||||
|
color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
.danger:hover {
|
||||||
|
color: var(--link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
/* === Main === */
|
/* === Main === */
|
||||||
main {
|
main {
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initMarkdown(root)
|
||||||
|
|
||||||
authKey, err := loadOrCreateAuthKey(root)
|
authKey, err := loadOrCreateAuthKey(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -183,6 +185,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
Crumbs: buildCrumbs(urlPath),
|
Crumbs: buildCrumbs(urlPath),
|
||||||
CanEdit: true,
|
CanEdit: true,
|
||||||
EditMode: editMode,
|
EditMode: editMode,
|
||||||
|
IsRoot: urlPath == "/",
|
||||||
SectionIndex: sectionIndex,
|
SectionIndex: sectionIndex,
|
||||||
PostURL: urlPath,
|
PostURL: urlPath,
|
||||||
RawContent: rawContent,
|
RawContent: rawContent,
|
||||||
@@ -198,6 +201,16 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
if query.Has("delete") {
|
||||||
|
h.handleDelete(w, r, urlPath, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := query["move"]; ok {
|
||||||
|
h.handleMove(w, r, urlPath, fsPath, query.Get("move"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL and
|
||||||
|
// rewrites every [[...]] wiki link across the tree that targets the old path
|
||||||
|
// or any descendant. All rewritten files are held in memory for rollback.
|
||||||
|
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string) {
|
||||||
|
oldPath := normalizeMovePath(srcURL)
|
||||||
|
if oldPath == "/" {
|
||||||
|
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath, err := validateAndNormalizeNewPath(dstURL)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid destination: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if newPath == oldPath {
|
||||||
|
http.Error(w, "destination equals source", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(newPath, oldPath+"/") {
|
||||||
|
http.Error(w, "destination is inside source", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if info, err := os.Stat(srcFsPath); err != nil || !info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/")))
|
||||||
|
if _, err := os.Stat(dstFsPath); err == nil {
|
||||||
|
http.Error(w, "destination already exists", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: walk the tree and rewrite every index.md whose content changes.
|
||||||
|
// Keep the pre-rewrite bytes in memory so we can revert on failure.
|
||||||
|
rewritten := map[string][]byte{}
|
||||||
|
walkErr := filepath.WalkDir(h.root, func(fsPath string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() || d.Name() != "index.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
orig, err := os.ReadFile(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated, changed := rewriteWikiLinks(orig, oldPath, newPath)
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(fsPath, updated, 0644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", fsPath, err)
|
||||||
|
}
|
||||||
|
rewritten[fsPath] = orig
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if walkErr != nil {
|
||||||
|
rollbackRewrites(rewritten)
|
||||||
|
http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: create intermediate parent folders for the destination.
|
||||||
|
if parent := filepath.Dir(dstFsPath); parent != "" {
|
||||||
|
if err := os.MkdirAll(parent, 0755); err != nil {
|
||||||
|
rollbackRewrites(rewritten)
|
||||||
|
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: rename the source folder into place.
|
||||||
|
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
|
||||||
|
rollbackRewrites(rewritten)
|
||||||
|
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDelete removes the folder at fsPath (URL urlPath) and redirects to
|
||||||
|
// the parent. Refuses to touch the wiki root.
|
||||||
|
func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
|
if normalizeMovePath(urlPath) == "/" {
|
||||||
|
http.Error(w, "cannot delete wiki root", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(fsPath); err != nil || !info.IsDir() {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(fsPath); err != nil {
|
||||||
|
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeMovePath returns the absolute path with any trailing slash removed,
|
||||||
|
// except for the wiki root which is always "/".
|
||||||
|
func normalizeMovePath(p string) string {
|
||||||
|
if p == "" || p == "/" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return "/" + strings.Trim(p, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAndNormalizeNewPath returns the cleaned absolute path or an error
|
||||||
|
// describing why the input was rejected. Empty/root paths, relative paths,
|
||||||
|
// and paths with bad segments are all invalid.
|
||||||
|
func validateAndNormalizeNewPath(raw string) (string, error) {
|
||||||
|
if !strings.HasPrefix(raw, "/") {
|
||||||
|
return "", fmt.Errorf("must start with /")
|
||||||
|
}
|
||||||
|
trimmed := strings.Trim(raw, "/")
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", fmt.Errorf("cannot target the wiki root")
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(trimmed, "/") {
|
||||||
|
if seg == "" {
|
||||||
|
return "", fmt.Errorf("empty segment")
|
||||||
|
}
|
||||||
|
if seg == "." || seg == ".." {
|
||||||
|
return "", fmt.Errorf("segment %q is not allowed", seg)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(seg, "\\\x00") {
|
||||||
|
return "", fmt.Errorf("segment contains an invalid character")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "/" + trimmed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
|
||||||
|
// [[target|display]] whose target equals oldPath or begins with oldPath+"/"
|
||||||
|
// has its target rewritten to the corresponding position under newPath.
|
||||||
|
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
|
||||||
|
changed := false
|
||||||
|
out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte {
|
||||||
|
parts := wikiLinkPattern.FindSubmatch(match)
|
||||||
|
if parts == nil {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
target := strings.TrimSpace(string(parts[1]))
|
||||||
|
normTarget := normalizeMovePath(target)
|
||||||
|
var newTarget string
|
||||||
|
switch {
|
||||||
|
case normTarget == oldPath:
|
||||||
|
newTarget = newPath
|
||||||
|
case strings.HasPrefix(normTarget, oldPath+"/"):
|
||||||
|
newTarget = newPath + strings.TrimPrefix(normTarget, oldPath)
|
||||||
|
default:
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
suffix := ""
|
||||||
|
if len(parts[2]) > 0 {
|
||||||
|
suffix = string(parts[2])
|
||||||
|
}
|
||||||
|
return []byte("[[" + newTarget + suffix + "]]")
|
||||||
|
})
|
||||||
|
return out, changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollbackRewrites restores the given files to their pre-rewrite contents.
|
||||||
|
// Errors are logged; best-effort since we're already in a failure path.
|
||||||
|
func rollbackRewrites(rewritten map[string][]byte) {
|
||||||
|
for path, orig := range rewritten {
|
||||||
|
if err := writeFileAtomic(path, orig, 0644); err != nil {
|
||||||
|
log.Printf("rollback %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileAtomic writes data to a temp file in the same directory as path
|
||||||
|
// and renames it into place so readers never observe a partial file.
|
||||||
|
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
cleanup := true
|
||||||
|
defer func() {
|
||||||
|
if cleanup {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if _, err := tmp.Write(data); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmpPath, perm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cleanup = false
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -15,11 +15,18 @@ import (
|
|||||||
"github.com/yuin/goldmark/renderer/html"
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
var md = goldmark.New(
|
var md goldmark.Markdown
|
||||||
goldmark.WithExtensions(extension.GFM, extension.Table),
|
|
||||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
// initMarkdown builds the package-level goldmark instance. Called once from
|
||||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
// main after the wiki root is known so the wiki-link extension can resolve
|
||||||
)
|
// targets against the filesystem.
|
||||||
|
func initMarkdown(root string) {
|
||||||
|
md = goldmark.New(
|
||||||
|
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root)),
|
||||||
|
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||||
|
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
type crumb struct{ Name, URL string }
|
type crumb struct{ Name, URL string }
|
||||||
type entry struct {
|
type entry struct {
|
||||||
@@ -32,6 +39,7 @@ type pageData struct {
|
|||||||
Crumbs []crumb
|
Crumbs []crumb
|
||||||
CanEdit bool
|
CanEdit bool
|
||||||
EditMode bool
|
EditMode bool
|
||||||
|
IsRoot bool
|
||||||
SectionIndex int // -1 = whole page; >=0 = section being edited
|
SectionIndex int // -1 = whole page; >=0 = section being edited
|
||||||
PostURL string
|
PostURL string
|
||||||
RawContent string
|
RawContent string
|
||||||
|
|||||||
+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