Compare commits
2 Commits
9639a70572
...
e8992235d4
| Author | SHA1 | Date | |
|---|---|---|---|
| e8992235d4 | |||
| e0d2fb0b41 |
@@ -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="stylesheet" href="/_/style.css" />
|
||||
<script src="/_/global-shortcuts.js"></script>
|
||||
<script src="/_/page-actions.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -24,6 +25,10 @@
|
||||
{{else if .CanEdit}}
|
||||
<button class="btn" onclick="newPage()" title="New page (N)">NEW</button>
|
||||
<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}}
|
||||
</header>
|
||||
<main>
|
||||
|
||||
@@ -59,6 +59,15 @@ a: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 {
|
||||
padding: 0.75rem 1rem;
|
||||
@@ -124,6 +133,14 @@ header {
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
|
||||
/* Destructive action */
|
||||
.danger {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
.danger:hover {
|
||||
color: var(--link-hover);
|
||||
}
|
||||
|
||||
/* === Main === */
|
||||
main {
|
||||
max-width: 860px;
|
||||
|
||||
@@ -51,6 +51,8 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
initMarkdown(root)
|
||||
|
||||
authKey, err := loadOrCreateAuthKey(root)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
@@ -183,6 +185,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
Crumbs: buildCrumbs(urlPath),
|
||||
CanEdit: true,
|
||||
EditMode: editMode,
|
||||
IsRoot: urlPath == "/",
|
||||
SectionIndex: sectionIndex,
|
||||
PostURL: urlPath,
|
||||
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) {
|
||||
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 {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"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 that references the
|
||||
// moved path. Keep the pre-rewrite bytes in memory so we can revert on
|
||||
// failure. The walker only reads directory listings and files literally
|
||||
// named index.md; hidden directories are pruned. A cheap substring check
|
||||
// skips parsing files that cannot contain a relevant link.
|
||||
rewritten := map[string][]byte{}
|
||||
needle := []byte("[[" + oldPath)
|
||||
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
|
||||
orig, err := os.ReadFile(fsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Contains(orig, needle) {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// walkIndexFiles visits every `index.md` under root, skipping hidden
|
||||
// directories (names beginning with `.`). Unlike filepath.WalkDir this does
|
||||
// not stat each regular file — on spinning disks that saves the bulk of the
|
||||
// traversal cost when folders contain many non-page files (photos, archives).
|
||||
func walkIndexFiles(root string, visit func(fsPath string) error) error {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(root, name)
|
||||
if e.IsDir() {
|
||||
if err := walkIndexFiles(full, visit); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name == "index.md" {
|
||||
if err := visit(full); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
var md = goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM, extension.Table),
|
||||
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
||||
goldmark.WithRendererOptions(html.WithUnsafe()),
|
||||
)
|
||||
var md goldmark.Markdown
|
||||
|
||||
// initMarkdown builds the package-level goldmark instance. Called once from
|
||||
// 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 entry struct {
|
||||
@@ -32,6 +39,7 @@ type pageData struct {
|
||||
Crumbs []crumb
|
||||
CanEdit bool
|
||||
EditMode bool
|
||||
IsRoot bool
|
||||
SectionIndex int // -1 = whole page; >=0 = section being edited
|
||||
PostURL 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