diff --git a/README.md b/README.md
index 5536bc2..75eeb02 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ Minimal self-hosted personal wiki. Folders are pages.
- **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
-- **Wikilinks** link between pages with `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
+- **Wikilinks** link between pages with `[[search query]]` syntax, or `[[query::display text]]` to override the label. The link opens the search page for that query, so it keeps resolving after a page is renamed or moved. Files embed with standard Markdown image syntax — `` — relative to the page they sit on.
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
diff --git a/assets/editor/main.html b/assets/editor/main.html
index d934e2d..a7561ee 100644
--- a/assets/editor/main.html
+++ b/assets/editor/main.html
@@ -30,10 +30,11 @@
-
+
+
diff --git a/assets/editor/main.js b/assets/editor/main.js
index 5256c67..351646a 100644
--- a/assets/editor/main.js
+++ b/assets/editor/main.js
@@ -223,6 +223,7 @@
quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink,
+ embed: insertEmbed,
ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); },
@@ -240,43 +241,34 @@
movie: function () { M.run(movieCtx); },
};
- // isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
- // path, no empty/dot segments. Used to gate the modal's INSERT button.
- function isValidWikiTarget(p) {
- if (!p || p[0] !== '/') return false;
- var trimmed = p.replace(/^\/+|\/+$/g, '');
- if (trimmed === '') return true;
- var segs = trimmed.split('/');
- for (var i = 0; i < segs.length; i++) {
- if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
- }
- return true;
- }
-
- // Wiki link button (ALT+SHIFT+P): open a modal with a target field backed by
- // full /_search typeahead plus an optional display-text field, then insert
- // [[target]] or [[target::display]] at the cursor. (Inline `[[` typing uses
- // the folder-scoped completion in wikicomplete.js instead.)
+ // Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
+ // plus an optional display-text field, inserting [[query]] or
+ // [[query::display]] at the cursor. Picking a suggestion only fills in that
+ // page's name — the link resolves through search at click time, so a path
+ // never enters the document. Inline `[[` typing uses the same suggestions
+ // via wikicomplete.js.
function insertWikilink() {
var sel = view.state.selection.main;
- var selectedText = view.state.sliceDoc(sel.from, sel.to);
+ var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
var container = document.createElement('div');
- var targetWrap = document.createElement('div');
- var targetInput = document.createElement('input');
- targetInput.type = 'text';
- targetInput.className = 'input';
- targetInput.placeholder = 'Page path or search…';
- targetWrap.appendChild(targetInput);
+ var queryWrap = document.createElement('div');
+ var queryInput = document.createElement('input');
+ queryInput.type = 'text';
+ queryInput.className = 'input';
+ queryInput.placeholder = 'Search query';
+ // A selection is the query, not the label: [[selection]] renders with
+ // the same visible text anyway, and it seeds the typeahead.
+ queryInput.value = selectedText;
+ queryWrap.appendChild(queryInput);
var displayInput = document.createElement('input');
displayInput.type = 'text';
displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)';
- if (selectedText) displayInput.value = selectedText;
- container.appendChild(targetWrap);
+ container.appendChild(queryWrap);
container.appendChild(displayInput);
var handle = openModal({
@@ -284,30 +276,104 @@
body: container,
confirm: {
label: 'INSERT',
- initiallyDisabled: true,
+ initiallyDisabled: !selectedText,
onConfirm: function () {
- var target = targetInput.value.trim();
- if (!isValidWikiTarget(target)) return;
+ var query = queryInput.value.trim();
+ if (!query) return;
var display = displayInput.value.trim();
handle.close();
- insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
+ insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
}
}
});
function updateConfirm() {
- handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
+ handle.setConfirmDisabled(!queryInput.value.trim());
}
- targetInput.addEventListener('input', updateConfirm);
+ queryInput.addEventListener('input', updateConfirm);
- window.attachSuggestions(targetInput, {
+ window.attachSuggestions(queryInput, {
showFooter: false,
- container: targetWrap,
+ container: queryWrap,
onPick: function (r) {
- targetInput.value = '/' + r.path;
+ queryInput.value = r.name;
updateConfirm();
displayInput.focus();
- displayInput.select();
+ }
+ });
+ }
+
+ // embedDest turns the absolute wiki path the tree picker hands back into the
+ // destination written to the document: relative to the page being edited
+ // when the file lives under it, absolute otherwise. Pages render at a
+ // slash-terminated URL, so a relative destination resolves against the page
+ // in the browser.
+ function embedDest(absPath) {
+ var base = decodeURIComponent(window.location.pathname);
+ if (base.slice(-1) !== '/') base += '/';
+ return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
+ }
+
+ // embedToken formats a Markdown image. A destination carrying spaces or
+ // brackets goes in — CommonMark's way of keeping it in one
+ // piece — and the title slot holds the alignment keyword the renderer reads.
+ // Right is the renderer's default, so it is left off.
+ function embedToken(dest, caption, align) {
+ if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
+ var title = (align && align !== 'right') ? ' "' + align + '"' : '';
+ return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
+ }
+
+ // Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
+ // caption and an alignment. Images render as a floated figure; anything
+ // else degrades to a link.
+ function insertEmbed() {
+ var sel = view.state.selection.main;
+ var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
+ var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
+
+ openTreePicker({
+ title: 'Embed — pick a file',
+ mode: 'file',
+ initialPath: here,
+ confirmLabel: 'NEXT',
+ onSelect: function (absPath) {
+ var container = document.createElement('div');
+
+ var captionInput = document.createElement('input');
+ captionInput.type = 'text';
+ captionInput.className = 'input';
+ captionInput.placeholder = 'Caption (optional)';
+ captionInput.value = selectedText;
+
+ var alignSelect = document.createElement('select');
+ alignSelect.className = 'input';
+ [['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
+ .forEach(function (opt) {
+ var o = document.createElement('option');
+ o.value = opt[0];
+ o.textContent = opt[1];
+ alignSelect.appendChild(o);
+ });
+
+ container.appendChild(captionInput);
+ container.appendChild(alignSelect);
+
+ var handle = openModal({
+ title: 'Embed — caption?',
+ body: container,
+ confirm: {
+ label: 'INSERT',
+ onConfirm: function () {
+ handle.close();
+ insertAtCursor(embedToken(
+ embedDest(absPath),
+ captionInput.value.trim(),
+ alignSelect.value
+ ));
+ }
+ }
+ });
}
});
}
diff --git a/assets/editor/wikicomplete.js b/assets/editor/wikicomplete.js
index 7398bbb..79232ce 100644
--- a/assets/editor/wikicomplete.js
+++ b/assets/editor/wikicomplete.js
@@ -1,110 +1,68 @@
-// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
+// wikicomplete.js — the `[[` autocomplete source for CodeMirror.
//
-// A level-by-level folder/file browser scoped to the path typed so far. It
-// fires only once the `[[` token's content begins with `/` (targets are
-// absolute; free-text search lives in the toolbar modal instead). The content
-// is split into a parent path (up to and including the last `/`) and a partial
-// segment (the text after it); the parent's children are fetched from the
-// existing `?tree=1` endpoint and filtered to names containing the partial
-// (case-insensitive substring).
+// Wiki links are search queries, so the popup is the same typeahead the header
+// search box uses: the text typed after `[[` goes to `/_search` and each match
+// is offered by name. Picking one replaces the query segment with that page's
+// name — never a path — so what lands in the document is still a query. The
+// trailing `]]` and any `::display` tail are left untouched.
//
-// Picking a folder inserts `/` and re-opens the popup to drill one level
-// deeper; picking a file inserts `` and stops. Only the current segment
-// is replaced, so the trailing `]]` stays put and the cursor parks before it,
-// leaving room to type a `::display` alias. Exposes window.WikiComplete.source
-// for main.js to register via CM's autocompletion().
+// Exposes window.WikiComplete.source for main.js to register via CM's
+// autocompletion().
window.WikiComplete = (function () {
- // treeURL builds the `?tree=1` request URL for an absolute parent path,
- // percent-encoding each segment. A leading/trailing slash is tolerated;
- // root resolves to `/?tree=1`.
- function treeURL(parent) {
- var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
- if (trimmed === '') return '/?tree=1';
- var enc = trimmed.split('/').map(encodeURIComponent).join('/');
- return '/' + enc + '/?tree=1';
- }
+ // Matches search-suggest.js so both surfaces fire at the same point.
+ var MIN_QUERY_LEN = 2;
- function fetchTree(parent) {
- return fetch(treeURL(parent), {
+ function fetchSuggestions(query) {
+ return fetch('/_search?q=' + encodeURIComponent(query), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}).then(function (r) {
- // A 404 means the parent folder doesn't exist (typo, or a path under
- // a file) — treat it as "no completions", not an error.
- if (r.status === 404) return null;
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
- // makeApply builds the apply() for a chosen entry. It replaces only the
- // current segment ([from, to]); the trailing `]]` is untouched, so the
- // cursor ends up parked before it. Folders append `/` and re-open the popup
- // to drill into the next level; files terminate.
- function makeApply(name, kind) {
- return function (view, completion, from, to) {
- var isFolder = kind === 'folder';
- var insert = isFolder ? name + '/' : name;
- view.dispatch({
- changes: { from: from, to: to, insert: insert },
- selection: { anchor: from + insert.length },
- scrollIntoView: true,
- });
- if (isFolder) {
- // Re-open after the transaction so the completion plugin sees
- // the updated document (the next level's parent path).
- setTimeout(function () { CM.startCompletion(view); }, 0);
- }
- };
+ // queryEnd returns where the current query segment ends: the first `]`, the
+ // `::` that starts the display text, or end of line. Replacing only up to
+ // there keeps a re-edit from eating an existing alias or the closing `]]`.
+ function queryEnd(context, pos) {
+ var line = context.state.doc.lineAt(pos);
+ var to = pos;
+ while (to < line.to) {
+ var ch = context.state.sliceDoc(to, to + 1);
+ if (ch === ']') break;
+ if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
+ to++;
+ }
+ return to;
}
- // CM completion source. Activates when `[[` is followed by content that
- // begins with `/`. The content is split at its last `/` into a parent path
- // and a partial segment; the parent's children are fetched, filtered to
- // names containing the partial (case-insensitive substring), and offered
- // with name-only labels.
+ // CM completion source. Activates on `[[` followed by at least
+ // MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
+ // gets nothing.
function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null;
var content = context.state.sliceDoc(match.from + 2, context.pos);
- if (content[0] !== '/') return null;
+ if (content.indexOf('::') !== -1) return null;
+ var query = content.trim();
+ if (query.length < MIN_QUERY_LEN) return null;
- var lastSlash = content.lastIndexOf('/');
- var parent = content.slice(0, lastSlash + 1);
- var partial = content.slice(lastSlash + 1);
-
- // Replace only the current segment: from the start of the partial up to
- // the next `/` or `]` (or end of line). This narrows re-edits inside an
- // existing `[[…]]` so drilling doesn't duplicate trailing text.
- var from = match.from + 2 + lastSlash + 1;
- var line = context.state.doc.lineAt(context.pos);
- var to = context.pos;
- while (to < line.to) {
- var ch = context.state.sliceDoc(to, to + 1);
- if (ch === '/' || ch === ']') break;
- to++;
- }
+ var from = match.from + 2;
+ var to = queryEnd(context, context.pos);
return new Promise(function (resolve) {
if (context.aborted) { resolve(null); return; }
- fetchTree(parent).then(function (resp) {
- if (context.aborted || !resp) { resolve(null); return; }
- var needle = partial.toLowerCase();
- var options = (resp.entries || []).reduce(function (acc, e) {
- if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
- return acc;
- }
- acc.push({
- label: e.name,
- type: e.kind === 'folder' ? 'folder' : 'file',
- apply: makeApply(e.name, e.kind),
- });
- return acc;
- }, []);
- // No validFor: the source re-runs on each keystroke, so every
- // edit (more chars, backspace, or a `/` that drills into the
- // next folder) re-fetches and re-filters from scratch.
- resolve({ from: from, to: to, options: options });
+ fetchSuggestions(query).then(function (resp) {
+ if (context.aborted) { resolve(null); return; }
+ var options = (resp.results || []).map(function (r) {
+ return { label: r.name, detail: '/' + r.path, apply: r.name };
+ });
+ // filter:false — the server already ranked these fuzzily, and
+ // CM's own prefix filter would drop the near-misses (typos,
+ // token reorderings) that make the fuzzy search worth having.
+ // No validFor either: every keystroke re-queries from scratch.
+ resolve({ from: from, to: to, options: options, filter: false });
}).catch(function () {
resolve(null);
});
diff --git a/assets/page/actions.js b/assets/page/actions.js
index bf3571b..92dbb59 100644
--- a/assets/page/actions.js
+++ b/assets/page/actions.js
@@ -129,23 +129,9 @@ function movePage() {
input.placeholder = 'Page name';
input.value = currentName;
- var linksCheckbox = document.createElement('input');
- linksCheckbox.type = 'checkbox';
- linksCheckbox.id = 'move-update-links';
-
- var linksLabel = document.createElement('label');
- linksLabel.htmlFor = linksCheckbox.id;
- linksLabel.className = 'row';
- linksLabel.appendChild(linksCheckbox);
- linksLabel.appendChild(document.createTextNode('Update links'));
-
- var body = document.createDocumentFragment();
- body.appendChild(input);
- body.appendChild(linksLabel);
-
openModal({
title: 'Move — new name?',
- body: body,
+ body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
@@ -154,7 +140,6 @@ function movePage() {
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' +
encodeURIComponent(dest);
- if (linksCheckbox.checked) action += '&links=1';
var target = encodePickedPath(dest) + '/';
closeModal();
submitMove(action, target);
diff --git a/assets/style.css b/assets/style.css
index 9c23fef..aca47fc 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -75,8 +75,6 @@ a { color: var(--text); text-decoration: none; }
a:hover { color: var(--link-hover); }
.content a { color: var(--link); }
.content a:hover { color: var(--link-hover); }
-.content a.broken { color: var(--link-hover); text-decoration: line-through; }
-.content a.broken:hover { color: var(--link-hover); }
hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
diff --git a/embed.go b/embed.go
index 0777a4f..b14b26a 100644
--- a/embed.go
+++ b/embed.go
@@ -3,7 +3,7 @@ package main
import (
"bytes"
"path"
- "regexp"
+ "strconv"
"strings"
"github.com/yuin/goldmark"
@@ -14,105 +14,95 @@ import (
"github.com/yuin/goldmark/util"
)
-// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
-// The inner blob forbids brackets and newlines but allows ':' so the whole
-// target::caption::align payload travels as one capture — the caption and
-// alignment tail is split apart in Go (parseEmbedFields). This mirrors
-// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
-var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
-
+// Embeds are ordinary Markdown images: .
+//
+// Destinations are relative to the page they sit on. Pages always render at a
+// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
+// resolves the destination and nothing here builds a URL or touches the
+// filesystem. Goldmark's own image parser produces the nodes; only rendering
+// is overridden. The title slot carries the float alignment.
const (
alignLeft = "left"
alignRight = "right"
alignCenter = "center"
+
+ // embedThumbW is the width embeds request through the ?w= form of the
+ // file's own URL, which keeps the relative.
+ embedThumbW = 300
)
-// wikiEmbedNode is the AST node produced by wikiEmbedParser.
-type wikiEmbedNode struct {
- ast.BaseInline
- Target string
- Caption string
- Align string
-}
-
-var kindWikiEmbed = ast.NewNodeKind("WikiEmbed")
-
-func (n *wikiEmbedNode) Kind() ast.NodeKind { return kindWikiEmbed }
-
-func (n *wikiEmbedNode) Dump(source []byte, level int) {
- ast.DumpHelper(n, source, level, map[string]string{
- "Target": n.Target,
- "Caption": n.Caption,
- "Align": n.Align,
- }, nil)
-}
-
-// alignKeyword reports whether s (trimmed) is one of the alignment keywords.
-func alignKeyword(s string) (string, bool) {
- switch strings.TrimSpace(s) {
+// embedAlign maps an image title to its float class. A title that is not an
+// alignment keyword is ignored and the embed floats right.
+func embedAlign(title []byte) string {
+ switch strings.TrimSpace(string(title)) {
case alignLeft:
- return alignLeft, true
- case alignRight:
- return alignRight, true
+ return alignLeft
case alignCenter:
- return alignCenter, true
+ return alignCenter
}
- return "", false
+ return alignRight
}
-// parseEmbedFields splits the inner ![[...]] blob into target, caption, and
-// alignment per the embed syntax rules. The target is the field before the
-// first "::". The trailing "::"-fields are interpreted as:
-// - none: no caption, default alignment (right)
-// - one keyword field: that alignment, no caption
-// - one non-keyword field: that caption, default alignment
-// - two or more: the last field is the alignment slot (default when it is not
-// a keyword) and the earlier fields re-join with "::" as the caption, so a
-// caption may contain a literal "::" as long as an alignment field trails it.
-func parseEmbedFields(inner string) (target, caption, align string) {
- fields := strings.Split(inner, "::")
- target = strings.TrimSpace(fields[0])
- tail := fields[1:]
- align = alignRight
- switch len(tail) {
- case 0:
- // target only
- case 1:
- if kw, ok := alignKeyword(tail[0]); ok {
- align = kw
+// embedIsImage reports whether a destination names an image, i.e. whether it
+// renders as a rather than degrading to a link. Extension-only — no
+// stat, so a destination gone stale simply 404s when clicked.
+func embedIsImage(dest []byte) bool {
+ return isImageFile(path.Base(string(dest)))
+}
+
+type embedRenderer struct{}
+
+func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+ reg.Register(ast.KindImage, r.render)
+}
+
+// render writes a holding a thumbnail that links to the full file,
+// with the image's inline children as . Children are rendered by
+// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
+// emphasis or a link. A non-image destination renders as a plain anchor
+// labelled with the caption, or with the file name when there is none.
+func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+ n := node.(*ast.Image)
+ name := path.Base(string(n.Destination))
+ dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
+ captioned := n.FirstChild() != nil
+
+ if !embedIsImage(n.Destination) {
+ if entering {
+ w.WriteString(``)
+ if !captioned {
+ w.Write(util.EscapeHTML([]byte(name)))
+ }
} else {
- caption = strings.TrimSpace(tail[0])
+ w.WriteString(``)
}
- default:
- if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
- align = kw
+ return ast.WalkContinue, nil
+ }
+
+ if entering {
+ w.WriteString(``)
+ if captioned {
+ w.WriteString(``)
}
- caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
+ return ast.WalkContinue, nil
}
- return target, caption, align
-}
-
-type wikiEmbedParser struct{}
-
-func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
-
-func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
- line, _ := block.PeekLine()
- // Require the exact "")
- // falls through to goldmark's default image parser at priority 200.
- if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
- return nil
+ if captioned {
+ w.WriteString(``)
}
- m := wikiEmbedRe.FindSubmatchIndex(line)
- if m == nil {
- return nil
- }
- target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
- if !isValidWikiTarget([]byte(target)) {
- return nil
- }
- block.Advance(m[1])
- return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
+ w.WriteString(``)
+ return ast.WalkContinue, nil
}
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
@@ -122,7 +112,7 @@ func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Co
// and breaks the float layout once several embeds share a page. Dissolving the
// wrapping paragraph makes each embed render as a clean block sibling with no
// stray
.
-type embedBlockTransformer struct{ root string }
+type embedBlockTransformer struct{}
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source()
@@ -144,7 +134,7 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
// block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; {
next := c.NextSibling()
- if _, ok := c.(*wikiEmbedNode); ok {
+ if _, ok := c.(*ast.Image); ok {
parent.InsertBefore(parent, p, c)
}
c = next
@@ -154,16 +144,16 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
}
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
-// embed and nothing else visible — only such embeds and whitespace/line-break
-// text. Only these are safe to dissolve: a paragraph carrying prose, or an embed
-// that degrades to an inline link (missing / non-image target), stays wrapped so
-// the fallback anchor keeps its paragraph.
+// image and nothing else visible — only such images and whitespace/line-break
+// text. Only these are safe to dissolve: a paragraph carrying prose, or an
+// image that degrades to an inline link (non-image destination), stays wrapped
+// so the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) {
- case *wikiEmbedNode:
- if !embedIsImage(t.root, n.Target) {
+ case *ast.Image:
+ if !embedIsImage(n.Destination) {
return false
}
hasFigure = true
@@ -178,77 +168,19 @@ func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []b
return hasFigure
}
-// embedIsImage reports whether target resolves to an existing image file, i.e.
-// the embed will render as a rather than degrade to a link fallback.
-func embedIsImage(root, target string) bool {
- name := path.Base(normalizeWikiTarget(target))
- return wikiTargetExists(root, target) && isImageFile(name)
-}
+type embedExt struct{}
-type wikiEmbedRenderer struct {
- root string
-}
+// newEmbedExt returns a goldmark extension that renders Markdown images as
+// wiki embeds. It adds no parser of its own: goldmark parses the images, and
+// the renderer registered at 500 wins over the default HTML renderer
+// (registered at 1000 — lowest priority number is applied last and wins).
+func newEmbedExt() goldmark.Extender { return &embedExt{} }
-func (r *wikiEmbedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
- reg.Register(kindWikiEmbed, r.render)
-}
-
-func (r *wikiEmbedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
- if !entering {
- return ast.WalkContinue, nil
- }
- n := node.(*wikiEmbedNode)
-
- // Embed only when the target exists and is an image we can thumbnail.
- // Anything else — a missing target, or an existing non-image file — degrades
- // to the same anchor a plain [[wikilink]] would render (broken or working).
- if !embedIsImage(r.root, n.Target) {
- writeWikiLink(w, r.root, n.Target, "")
- return ast.WalkContinue, nil
- }
-
- fileHref := wikiFileHref(n.Target)
- alt := n.Caption
- if alt == "" {
- alt = path.Base(normalizeWikiTarget(n.Target))
- }
-
- w.WriteString(``)
- if n.Caption != "" {
- w.WriteString(``)
- w.Write(util.EscapeHTML([]byte(n.Caption)))
- w.WriteString(``)
- }
- w.WriteString(``)
- return ast.WalkContinue, nil
-}
-
-type wikiEmbedExt struct{ root string }
-
-// newWikiEmbedExt returns a goldmark extension that turns ![[...]] tokens into
-// image embeds resolved against root.
-func newWikiEmbedExt(root string) goldmark.Extender {
- return &wikiEmbedExt{root: root}
-}
-
-func (e *wikiEmbedExt) Extend(m goldmark.Markdown) {
- // Priority 199 — one higher than the default image parser (200) so ![[...]]
- // is consumed as an embed before the default `!` parser sees it.
- m.Parser().AddOptions(parser.WithInlineParsers(
- util.Prioritized(&wikiEmbedParser{}, 199),
- ))
+func (e *embedExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
- util.Prioritized(embedBlockTransformer{root: e.root}, 100),
+ util.Prioritized(embedBlockTransformer{}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
- util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
+ util.Prioritized(&embedRenderer{}, 500),
))
}
diff --git a/main.go b/main.go
index 924b2a6..795e492 100644
--- a/main.go
+++ b/main.go
@@ -10,6 +10,7 @@ import (
"io/fs"
"log"
"net/http"
+ "net/url"
"os"
"path"
"path/filepath"
@@ -105,7 +106,7 @@ func main() {
log.Fatal(err)
}
- initMarkdown(root)
+ initMarkdown()
authKey, err := loadOrCreateAuthKey(root)
if err != nil {
@@ -220,7 +221,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// POSTs must not be redirected — the form action has no trailing
// slash (path.Clean strips it) and the content would be lost.
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
- http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
+ http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
return
}
h.serveDir(w, r, urlPath, fsPath)
@@ -232,14 +233,39 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if info.IsDir() {
if urlPath != "/" {
+ // Pages must live at a slash-terminated URL: embed destinations are
+ // relative, so "/Trips/Japan" would resolve them against "/Trips/".
+ if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
+ http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
+ return
+ }
urlPath += "/"
}
h.serveDir(w, r, urlPath, fsPath)
} else {
+ // ?w= on a file's own URL serves a thumbnail. Embeds use this form so
+ // their can stay relative to the page; /_thumb/
+ // stays for listings, which have absolute paths on hand anyway. A file
+ // with no thumbnailer ignores the parameter and serves normally.
+ if r.URL.Query().Has("w") && hasThumbnail(fsPath) {
+ h.serveThumbnail(w, r, fsPath)
+ return
+ }
http.ServeFile(w, r, fsPath)
}
}
+// slashedURL returns u's path with a trailing slash appended and the query
+// string intact — the canonical page form that relative destinations resolve
+// against.
+func slashedURL(u *url.URL) string {
+ target := u.Path + "/"
+ if u.RawQuery != "" {
+ target += "?" + u.RawQuery
+ }
+ return target
+}
+
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
_, editMode := r.URL.Query()["edit"]
@@ -373,7 +399,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return
}
if _, ok := query["move"]; ok {
- h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"), query.Has("merge"))
+ h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
return
}
if query.Has("toggle") {
diff --git a/moves.go b/moves.go
index 5d9621f..bd3a953 100644
--- a/moves.go
+++ b/moves.go
@@ -1,19 +1,14 @@
package main
import (
- "bytes"
"fmt"
- "log"
"net/http"
"os"
"path/filepath"
"strings"
)
-// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When
-// updateLinks is true it also rewrites every [[...]] wiki link across the
-// tree that targets the old path or any descendant; rewritten files are held
-// in memory for rollback.
+// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL.
//
// If the destination folder already exists it is normally a hard conflict.
// The one exception is a merge: when the source carries a page (index.md) and
@@ -21,7 +16,7 @@ import (
// container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is told a merge is available so it can ask the
// user to confirm.
-func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks, merge bool) {
+func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
oldPath := normalizeMovePath(srcURL)
if oldPath == "/" {
http.Error(w, "cannot move wiki root", http.StatusBadRequest)
@@ -74,69 +69,32 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
merging = true
}
- // Phase 1: optionally 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{}
- if updateLinks {
- 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.
+ // Phase 1: 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: move the source into place. A plain move renames the whole
+ // Phase 2: move the source into place. A plain move renames the whole
// folder; a merge moves the source's entries into the existing
// destination and drops the emptied source.
if merging {
if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
- rollbackRewrites(rewritten)
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} else {
if err := os.Rename(srcFsPath, dstFsPath); err != nil {
- rollbackRewrites(rewritten)
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return
}
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
}
- http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
+ http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
}
// canMergeMove reports whether moving onto an existing destination should
@@ -248,77 +206,6 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
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 {
diff --git a/render.go b/render.go
index 3bb5664..e5060ab 100644
--- a/render.go
+++ b/render.go
@@ -19,12 +19,12 @@ import (
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) {
+// initMarkdown builds the package-level goldmark instance. Neither extension
+// resolves anything against the filesystem — wiki links are search queries and
+// embed destinations are relative to the page — so this needs no wiki root.
+func initMarkdown() {
md = goldmark.New(
- goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root)),
+ goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()),
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
)
diff --git a/thumb.go b/thumb.go
index b179608..b50bb76 100644
--- a/thumb.go
+++ b/thumb.go
@@ -96,9 +96,14 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad path", http.StatusBadRequest)
return
}
- cleanPath := path.Clean(decoded)
+ h.serveThumbnail(w, r, filepath.Join(h.root, filepath.FromSlash(path.Clean(decoded))))
+}
- srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath))
+// serveThumbnail generates (or serves from cache) a thumbnail of the file at
+// srcFS, sized by the ?w= query. Both entry points land here: the /_thumb
+// route used by listings and the diary, and the ?w= query on a file's own URL,
+// which is the form embeds use so their stays relative to the page.
+func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS string) {
rel, err := filepath.Rel(h.root, srcFS)
if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "Forbidden", http.StatusForbidden)
diff --git a/wikilinks.go b/wikilinks.go
index 1598f9e..775201a 100644
--- a/wikilinks.go
+++ b/wikilinks.go
@@ -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(``)
+ w.WriteString(wikiSearchHref(query))
+ w.WriteString(`">`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(``)
}
-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),
))
}