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 — `![caption](photo.jpg "left")` — 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: ![caption](photo.jpg "left"). +// +// 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(`
`)
+		w.Write(util.EscapeHTML([]byte(name)))
+		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 "![[" opener; a bare '!' (or a Markdown image "![](…)") - // 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(`
`)
-	w.Write(util.EscapeHTML([]byte(alt)))
-	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), )) }