Compare commits

..
1 Commits
Author SHA1 Message Date
luxick 7797d4a456 JSON canvas initial 2026-09-17 17:07:04 +02:00
16 changed files with 664 additions and 356 deletions
-3
View File
@@ -11,6 +11,3 @@ companion/datascape-companion-*
# Editor build tooling deps (the built bundle is committed; node_modules is not) # Editor build tooling deps (the built bundle is committed; node_modules is not)
editor-build/node_modules/ editor-build/node_modules/
AGENTS.md
CLAUDE.md
+7
View File
@@ -0,0 +1,7 @@
# CLAUDE.md
I want to understand every line of code that goes into this project. Never create, edit, move, rename, or delete project files unless I explicitly ask you to do so. Instead, show me every proposed edit in the chat so I can type it in manually.
Do not run commands that modify project files, install dependencies, or change repository state unless I explicitly request that action. Instead, show me those commands in the chat so I can run them manually.
I'm an experienced developer. Do not explain syntax, APIs, programming concepts, or implementation details unless explicitly asked.
+4 -5
View File
@@ -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. - **Search** search across all page names (folder names) in the wiki, accessible from the navigation bar.
- **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. - **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.
- **Canvases** an infinite pan/zoom board stored as a [JSON Canvas](https://jsoncanvas.org/) `.canvas` file next to the page's `index.md`. Canvases in a folder appear as tabs above the page content, so a canvas is an alternative *view* of the page rather than a separate document. See the [Canvases](#canvases) section. - **Canvases** an infinite pan/zoom board stored as a [JSON Canvas](https://jsoncanvas.org/) `.canvas` file next to the page's `index.md`. Canvases in a folder appear as tabs above the page content, so a canvas is an alternative *view* of the page rather than a separate document. See the [Canvases](#canvases) section.
@@ -172,10 +172,9 @@ can never land on disk, and four things are checked in order:
The previous contents are kept beside the file as `.<Name>.canvas.bak`, and the The previous contents are kept beside the file as `.<Name>.canvas.bak`, and the
write itself is atomic. Leaving the page with unsaved changes prompts first. write itself is atomic. Leaving the page with unsaved changes prompts first.
`[[wikilinks]]` inside text nodes are search queries, like on a page, so they Moving or renaming a page rewrites the canvases that point at it — both the
keep resolving after a move. File nodes and group backgrounds hold literal `[[wikilinks]]` inside text nodes and the paths of file nodes and group
paths and are not rewritten: moving or renaming their target leaves them backgrounds — the same way it rewrites links in `index.md`.
rendered as broken references.
Canvas *contents* are not searchable; only the file name is indexed. Canvas *contents* are not searchable; only the file name is indexed.
-12
View File
@@ -186,19 +186,7 @@
}); });
} }
function isMobileDevice() {
// The companion only ships for Windows and Linux desktops, so skip it
// on phones/tablets. Checks the UA and falls back to "touch is the only
// pointer" so narrow desktop windows still get the companion.
if (/Android|iPhone|iPad|iPod|Mobi/i.test(navigator.userAgent)) return true;
return window.matchMedia('(pointer: coarse)').matches &&
!window.matchMedia('(any-pointer: fine)').matches;
}
function init() { function init() {
// Every companion element is rendered hidden, so bailing out here
// leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return;
probeStatus().then(function () { probeStatus().then(function () {
updateFooterIcon(); updateFooterIcon();
wireFileLinks(); wireFileLinks();
+1 -2
View File
@@ -30,11 +30,10 @@
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button> <button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button> <button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
<span class="dropdown"> <span class="dropdown">
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button> <button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P)">L▾</button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button> <button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button> <button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
</div> </div>
</span> </span>
<span class="dropdown"> <span class="dropdown">
+36 -102
View File
@@ -223,7 +223,6 @@
quote: function () { linePrefix('> '); }, quote: function () { linePrefix('> '); },
link: function () { wrap('[', '](url)', 'link text'); }, link: function () { wrap('[', '](url)', 'link text'); },
wikilink: insertWikilink, wikilink: insertWikilink,
embed: insertEmbed,
ul: function () { linePrefix('- '); }, ul: function () { linePrefix('- '); },
ol: function () { linePrefix('1. '); }, ol: function () { linePrefix('1. '); },
task: function () { linePrefix('- [ ] '); }, task: function () { linePrefix('- [ ] '); },
@@ -241,34 +240,43 @@
movie: function () { M.run(movieCtx); }, movie: function () { M.run(movieCtx); },
}; };
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead // isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
// plus an optional display-text field, inserting [[query]] or // path, no empty/dot segments. Used to gate the modal's INSERT button.
// [[query::display]] at the cursor. Picking a suggestion only fills in that function isValidWikiTarget(p) {
// page's name — the link resolves through search at click time, so a path if (!p || p[0] !== '/') return false;
// never enters the document. Inline `[[` typing uses the same suggestions var trimmed = p.replace(/^\/+|\/+$/g, '');
// via wikicomplete.js. 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.)
function insertWikilink() { function insertWikilink() {
var sel = view.state.selection.main; var sel = view.state.selection.main;
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim(); var selectedText = view.state.sliceDoc(sel.from, sel.to);
var container = document.createElement('div'); var container = document.createElement('div');
var queryWrap = document.createElement('div'); var targetWrap = document.createElement('div');
var queryInput = document.createElement('input'); var targetInput = document.createElement('input');
queryInput.type = 'text'; targetInput.type = 'text';
queryInput.className = 'input'; targetInput.className = 'input';
queryInput.placeholder = 'Search query'; targetInput.placeholder = 'Page path or search…';
// A selection is the query, not the label: [[selection]] renders with targetWrap.appendChild(targetInput);
// the same visible text anyway, and it seeds the typeahead.
queryInput.value = selectedText;
queryWrap.appendChild(queryInput);
var displayInput = document.createElement('input'); var displayInput = document.createElement('input');
displayInput.type = 'text'; displayInput.type = 'text';
displayInput.className = 'input'; displayInput.className = 'input';
displayInput.placeholder = 'Display text (optional)'; displayInput.placeholder = 'Display text (optional)';
if (selectedText) displayInput.value = selectedText;
container.appendChild(queryWrap); container.appendChild(targetWrap);
container.appendChild(displayInput); container.appendChild(displayInput);
var handle = openModal({ var handle = openModal({
@@ -276,104 +284,30 @@
body: container, body: container,
confirm: { confirm: {
label: 'INSERT', label: 'INSERT',
initiallyDisabled: !selectedText, initiallyDisabled: true,
onConfirm: function () { onConfirm: function () {
var query = queryInput.value.trim(); var target = targetInput.value.trim();
if (!query) return; if (!isValidWikiTarget(target)) return;
var display = displayInput.value.trim(); var display = displayInput.value.trim();
handle.close(); handle.close();
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]'); insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
} }
} }
}); });
function updateConfirm() { function updateConfirm() {
handle.setConfirmDisabled(!queryInput.value.trim()); handle.setConfirmDisabled(!isValidWikiTarget(targetInput.value.trim()));
} }
queryInput.addEventListener('input', updateConfirm); targetInput.addEventListener('input', updateConfirm);
window.attachSuggestions(queryInput, { window.attachSuggestions(targetInput, {
showFooter: false, showFooter: false,
container: queryWrap, container: targetWrap,
onPick: function (r) { onPick: function (r) {
queryInput.value = r.name; targetInput.value = '/' + r.path;
updateConfirm(); updateConfirm();
displayInput.focus(); 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 <angle brackets> — 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
));
}
}
});
} }
}); });
} }
+83 -41
View File
@@ -1,68 +1,110 @@
// wikicomplete.js — the `[[` autocomplete source for CodeMirror. // wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
// //
// Wiki links are search queries, so the popup is the same typeahead the header // A level-by-level folder/file browser scoped to the path typed so far. It
// search box uses: the text typed after `[[` goes to `/_search` and each match // fires only once the `[[` token's content begins with `/` (targets are
// is offered by name. Picking one replaces the query segment with that page's // absolute; free-text search lives in the toolbar modal instead). The content
// name — never a path — so what lands in the document is still a query. The // is split into a parent path (up to and including the last `/`) and a partial
// trailing `]]` and any `::display` tail are left untouched. // 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).
// //
// Exposes window.WikiComplete.source for main.js to register via CM's // Picking a folder inserts `<name>/` and re-opens the popup to drill one level
// autocompletion(). // deeper; picking a file inserts `<name>` 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().
window.WikiComplete = (function () { window.WikiComplete = (function () {
// Matches search-suggest.js so both surfaces fire at the same point. // treeURL builds the `?tree=1` request URL for an absolute parent path,
var MIN_QUERY_LEN = 2; // 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';
}
function fetchSuggestions(query) { function fetchTree(parent) {
return fetch('/_search?q=' + encodeURIComponent(query), { return fetch(treeURL(parent), {
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
}).then(function (r) { }).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); if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json(); return r.json();
}); });
} }
// queryEnd returns where the current query segment ends: the first `]`, the // makeApply builds the apply() for a chosen entry. It replaces only the
// `::` that starts the display text, or end of line. Replacing only up to // current segment ([from, to]); the trailing `]]` is untouched, so the
// there keeps a re-edit from eating an existing alias or the closing `]]`. // cursor ends up parked before it. Folders append `/` and re-open the popup
function queryEnd(context, pos) { // to drill into the next level; files terminate.
var line = context.state.doc.lineAt(pos); function makeApply(name, kind) {
var to = pos; return function (view, completion, from, to) {
while (to < line.to) { var isFolder = kind === 'folder';
var ch = context.state.sliceDoc(to, to + 1); var insert = isFolder ? name + '/' : name;
if (ch === ']') break; view.dispatch({
if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break; changes: { from: from, to: to, insert: insert },
to++; 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);
} }
return to; };
} }
// CM completion source. Activates on `[[` followed by at least // CM completion source. Activates when `[[` is followed by content that
// MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and // begins with `/`. The content is split at its last `/` into a parent path
// gets nothing. // 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.
function source(context) { function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/); var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null; if (!match) return null;
var content = context.state.sliceDoc(match.from + 2, context.pos); var content = context.state.sliceDoc(match.from + 2, context.pos);
if (content.indexOf('::') !== -1) return null; if (content[0] !== '/') return null;
var query = content.trim();
if (query.length < MIN_QUERY_LEN) return null;
var from = match.from + 2; var lastSlash = content.lastIndexOf('/');
var to = queryEnd(context, context.pos); 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++;
}
return new Promise(function (resolve) { return new Promise(function (resolve) {
if (context.aborted) { resolve(null); return; } if (context.aborted) { resolve(null); return; }
fetchSuggestions(query).then(function (resp) { fetchTree(parent).then(function (resp) {
if (context.aborted) { resolve(null); return; } if (context.aborted || !resp) { resolve(null); return; }
var options = (resp.results || []).map(function (r) { var needle = partial.toLowerCase();
return { label: r.name, detail: '/' + r.path, apply: r.name }; 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),
}); });
// filter:false — the server already ranked these fuzzily, and return acc;
// CM's own prefix filter would drop the near-misses (typos, }, []);
// token reorderings) that make the fuzzy search worth having. // No validFor: the source re-runs on each keystroke, so every
// No validFor either: every keystroke re-queries from scratch. // edit (more chars, backspace, or a `/` that drills into the
resolve({ from: from, to: to, options: options, filter: false }); // next folder) re-fetches and re-filters from scratch.
resolve({ from: from, to: to, options: options });
}).catch(function () { }).catch(function () {
resolve(null); resolve(null);
}); });
+16 -1
View File
@@ -129,9 +129,23 @@ function movePage() {
input.placeholder = 'Page name'; input.placeholder = 'Page name';
input.value = currentName; 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({ openModal({
title: 'Move — new name?', title: 'Move — new name?',
body: input, body: body,
confirm: { confirm: {
label: 'MOVE', label: 'MOVE',
onConfirm: function () { onConfirm: function () {
@@ -140,6 +154,7 @@ function movePage() {
var dest = (newParent === '/' ? '' : newParent) + '/' + name; var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' + var action = window.location.pathname + '?move=' +
encodeURIComponent(dest); encodeURIComponent(dest);
if (linksCheckbox.checked) action += '&links=1';
var target = encodePickedPath(dest) + '/'; var target = encodePickedPath(dest) + '/';
closeModal(); closeModal();
submitMove(action, target); submitMove(action, target);
+2
View File
@@ -86,6 +86,8 @@ a { color: var(--text); text-decoration: none; }
a:hover { color: var(--link-hover); } a:hover { color: var(--link-hover); }
.content a { color: var(--link); } .content a { color: var(--link); }
.content a:hover { color: var(--link-hover); } .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; } hr { border: none; border-top: var(--border-dashed); margin: var(--space-4) 0; }
+4 -4
View File
@@ -324,7 +324,7 @@ func canvasViewURL(urlPath string) (string, bool) {
if name == "" || name == base || !canvasNameRe.MatchString(name) { if name == "" || name == base || !canvasNameRe.MatchString(name) {
return "", false return "", false
} }
return canvasURL(fileURL(strings.Trim(path.Dir(urlPath), "/")), name), true return canvasURL(wikiTargetHref(path.Dir(urlPath)), name), true
} }
// listCanvases returns the canvases in a folder as view-switcher tabs, sorted // listCanvases returns the canvases in a folder as view-switcher tabs, sorted
@@ -388,7 +388,7 @@ func (h *handler) prerenderNodes(c *canvasDoc) map[string]template.HTML {
// <img> pointed at the existing thumbnail endpoint; anything else degrades to // <img> pointed at the existing thumbnail endpoint; anything else degrades to
// a link. A missing target renders visibly broken rather than blank. // a link. A missing target renders visibly broken rather than blank.
func (h *handler) renderFileNode(file, subpath string) template.HTML { func (h *handler) renderFileNode(file, subpath string) template.HTML {
target := "/" + strings.Trim(file, "/") target := normalizeWikiTarget(file)
fsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(target, "/"))) fsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(target, "/")))
if rel, err := filepath.Rel(h.root, fsPath); err != nil || strings.HasPrefix(rel, "..") { if rel, err := filepath.Rel(h.root, fsPath); err != nil || strings.HasPrefix(rel, "..") {
return canvasBrokenRef(file, "outside the wiki") return canvasBrokenRef(file, "outside the wiki")
@@ -408,11 +408,11 @@ func (h *handler) renderFileNode(file, subpath string) template.HTML {
} }
name := path.Base(target) name := path.Base(target)
href := html.EscapeString(fileURL(strings.TrimPrefix(target, "/"))) href := html.EscapeString(wikiFileHref(target))
switch { switch {
case isImageFile(name): case isImageFile(name):
return template.HTML(`<a class="canvas-media" href="` + href + `"><img src="` + return template.HTML(`<a class="canvas-media" href="` + href + `"><img src="` +
html.EscapeString(thumbURL(fileURL(strings.TrimPrefix(target, "/")), 800)) + `" alt="" loading="lazy"></a>`) html.EscapeString(thumbURL(wikiFileHref(target), 800)) + `" alt="" loading="lazy"></a>`)
case isVideoFile(name): case isVideoFile(name):
return template.HTML(`<video class="canvas-media" controls preload="metadata" src="` + href + `"></video>`) return template.HTML(`<video class="canvas-media" controls preload="metadata" src="` + href + `"></video>`)
case strings.EqualFold(path.Ext(name), ".pdf"): case strings.EqualFold(path.Ext(name), ".pdf"):
+157 -89
View File
@@ -3,7 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"path" "path"
"strconv" "regexp"
"strings" "strings"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@@ -14,95 +14,105 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
// Embeds are ordinary Markdown images: ![caption](photo.jpg "left"). // wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
// // The inner blob forbids brackets and newlines but allows ':' so the whole
// Destinations are relative to the page they sit on. Pages always render at a // target::caption::align payload travels as one capture — the caption and
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser // alignment tail is split apart in Go (parseEmbedFields). This mirrors
// resolves the destination and nothing here builds a URL or touches the // wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
// filesystem. Goldmark's own image parser produces the nodes; only rendering var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
// is overridden. The title slot carries the float alignment.
const ( const (
alignLeft = "left" alignLeft = "left"
alignRight = "right" alignRight = "right"
alignCenter = "center" alignCenter = "center"
// embedThumbW is the width embeds request through the ?w= form of the
// file's own URL, which keeps the <img src> relative.
embedThumbW = 300
) )
// embedAlign maps an image title to its float class. A title that is not an // wikiEmbedNode is the AST node produced by wikiEmbedParser.
// alignment keyword is ignored and the embed floats right. type wikiEmbedNode struct {
func embedAlign(title []byte) string { ast.BaseInline
switch strings.TrimSpace(string(title)) { 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) {
case alignLeft: case alignLeft:
return alignLeft return alignLeft, true
case alignRight:
return alignRight, true
case alignCenter: case alignCenter:
return alignCenter return alignCenter, true
} }
return alignRight return "", false
} }
// embedIsImage reports whether a destination names an image, i.e. whether it // parseEmbedFields splits the inner ![[...]] blob into target, caption, and
// renders as a <figure> rather than degrading to a link. Extension-only — no // alignment per the embed syntax rules. The target is the field before the
// stat, so a destination gone stale simply 404s when clicked. // first "::". The trailing "::"-fields are interpreted as:
func embedIsImage(dest []byte) bool { // - none: no caption, default alignment (right)
return isImageFile(path.Base(string(dest))) // - 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
type embedRenderer struct{} // 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 (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { func parseEmbedFields(inner string) (target, caption, align string) {
reg.Register(ast.KindImage, r.render) fields := strings.Split(inner, "::")
} target = strings.TrimSpace(fields[0])
tail := fields[1:]
// render writes a <figure> holding a thumbnail that links to the full file, align = alignRight
// with the image's inline children as <figcaption>. Children are rendered by switch len(tail) {
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry case 0:
// emphasis or a link. A non-image destination renders as a plain anchor // target only
// labelled with the caption, or with the file name when there is none. case 1:
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if kw, ok := alignKeyword(tail[0]); ok {
n := node.(*ast.Image) align = kw
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(`<a href="`)
w.Write(dest)
w.WriteString(`">`)
if !captioned {
w.Write(util.EscapeHTML([]byte(name)))
}
} else { } else {
w.WriteString(`</a>`) caption = strings.TrimSpace(tail[0])
} }
return ast.WalkContinue, nil default:
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
align = kw
}
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
}
return target, caption, align
} }
if entering { type wikiEmbedParser struct{}
w.WriteString(`<figure class="embed embed-`)
w.WriteString(embedAlign(n.Title)) func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
w.WriteString(`"><a class="embed-link" href="`)
w.Write(dest) func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
w.WriteString(`"><img src="`) line, _ := block.PeekLine()
w.Write(dest) // Require the exact "![[" opener; a bare '!' (or a Markdown image "![](…)")
w.WriteString(`?w=`) // falls through to goldmark's default image parser at priority 200.
w.WriteString(strconv.Itoa(embedThumbW)) if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
w.WriteString(`" alt="`) return nil
w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`"></a>`)
if captioned {
w.WriteString(`<figcaption>`)
} }
return ast.WalkContinue, nil m := wikiEmbedRe.FindSubmatchIndex(line)
if m == nil {
return nil
} }
if captioned { target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
w.WriteString(`</figcaption>`) if !isValidWikiTarget([]byte(target)) {
return nil
} }
w.WriteString(`</figure>`) block.Advance(m[1])
return ast.WalkContinue, nil return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
} }
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to // embedBlockTransformer lifts an embed that sits alone in a paragraph up to
@@ -112,7 +122,7 @@ func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, e
// and breaks the float layout once several embeds share a page. Dissolving the // 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 // wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>. // stray <p>.
type embedBlockTransformer struct{} type embedBlockTransformer struct{ root string }
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source() source := reader.Source()
@@ -134,7 +144,7 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
// block sibling ahead of the (soon removed) paragraph, then drop p. // block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; { for c := p.FirstChild(); c != nil; {
next := c.NextSibling() next := c.NextSibling()
if _, ok := c.(*ast.Image); ok { if _, ok := c.(*wikiEmbedNode); ok {
parent.InsertBefore(parent, p, c) parent.InsertBefore(parent, p, c)
} }
c = next c = next
@@ -144,16 +154,16 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
} }
// paragraphFiguresOnly reports whether p holds at least one figure-rendering // paragraphFiguresOnly reports whether p holds at least one figure-rendering
// image and nothing else visible — only such images and whitespace/line-break // 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 // text. Only these are safe to dissolve: a paragraph carrying prose, or an embed
// image that degrades to an inline link (non-image destination), stays wrapped // that degrades to an inline link (missing / non-image target), stays wrapped so
// so the fallback anchor keeps its paragraph. // the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool { func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() { for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) { switch n := c.(type) {
case *ast.Image: case *wikiEmbedNode:
if !embedIsImage(n.Destination) { if !embedIsImage(t.root, n.Target) {
return false return false
} }
hasFigure = true hasFigure = true
@@ -168,19 +178,77 @@ func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []b
return hasFigure return hasFigure
} }
type embedExt struct{} // embedIsImage reports whether target resolves to an existing image file, i.e.
// the embed will render as a <figure> rather than degrade to a link fallback.
func embedIsImage(root, target string) bool {
name := path.Base(normalizeWikiTarget(target))
return wikiTargetExists(root, target) && isImageFile(name)
}
// newEmbedExt returns a goldmark extension that renders Markdown images as type wikiEmbedRenderer struct {
// wiki embeds. It adds no parser of its own: goldmark parses the images, and root string
// 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 (e *embedExt) Extend(m goldmark.Markdown) { 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(`<figure class="embed embed-`)
w.WriteString(n.Align)
w.WriteString(`"><a class="embed-link" href="`)
w.WriteString(fileHref)
w.WriteString(`"><img src="`)
w.WriteString(thumbURL(fileHref, 300))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(alt)))
w.WriteString(`"></a>`)
if n.Caption != "" {
w.WriteString(`<figcaption>`)
w.Write(util.EscapeHTML([]byte(n.Caption)))
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
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),
))
m.Parser().AddOptions(parser.WithASTTransformers( m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{}, 100), util.Prioritized(embedBlockTransformer{root: e.root}, 100),
)) ))
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&embedRenderer{}, 500), util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
)) ))
} }
+3 -29
View File
@@ -10,7 +10,6 @@ import (
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -107,7 +106,7 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
initMarkdown() initMarkdown(root)
authKey, err := loadOrCreateAuthKey(root) authKey, err := loadOrCreateAuthKey(root)
if err != nil { if err != nil {
@@ -233,7 +232,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// POSTs must not be redirected — the form action has no trailing // POSTs must not be redirected — the form action has no trailing
// slash (path.Clean strips it) and the content would be lost. // slash (path.Clean strips it) and the content would be lost.
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost { if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently) http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return return
} }
h.serveDir(w, r, urlPath, fsPath) h.serveDir(w, r, urlPath, fsPath)
@@ -245,39 +244,14 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if info.IsDir() { if info.IsDir() {
if urlPath != "/" { 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 += "/" urlPath += "/"
} }
h.serveDir(w, r, urlPath, fsPath) h.serveDir(w, r, urlPath, fsPath)
} else { } else {
// ?w= on a file's own URL serves a thumbnail. Embeds use this form so
// their <img src> can stay relative to the page; /_thumb/<abs path>
// 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) 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) { func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
_, editMode := r.URL.Query()["edit"] _, editMode := r.URL.Query()["edit"]
@@ -439,7 +413,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
if _, ok := query["move"]; ok { if _, ok := query["move"]; ok {
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge")) h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("links"), query.Has("merge"))
return return
} }
if query.Has("toggle") { if query.Has("toggle") {
+225 -5
View File
@@ -1,14 +1,20 @@
package main package main
import ( import (
"bytes"
"encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. // 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.
// //
// If the destination folder already exists it is normally a hard conflict. // 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 // The one exception is a merge: when the source carries a page (index.md) and
@@ -16,7 +22,7 @@ import (
// container without clobbering anything. That merge only proceeds when merge // 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 // is true; otherwise the client is told a merge is available so it can ask the
// user to confirm. // user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) { func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks, merge bool) {
oldPath := normalizeMovePath(srcURL) oldPath := normalizeMovePath(srcURL)
if oldPath == "/" { if oldPath == "/" {
http.Error(w, "cannot move wiki root", http.StatusBadRequest) http.Error(w, "cannot move wiki root", http.StatusBadRequest)
@@ -69,32 +75,92 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
merging = true merging = true
} }
// Phase 1: create intermediate parent folders for the destination. // 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 {
// rewriteInto reads a file, hands it to rewrite, and writes the result
// back atomically — recording the original bytes so every file touched
// in this phase can be rolled back together.
rewriteInto := func(fsPath string, needle []byte, rewrite func([]byte) ([]byte, bool)) error {
orig, err := os.ReadFile(fsPath)
if err != nil {
return err
}
// Cheap reject before parsing: a file that never mentions the old
// path cannot contain a reference to it.
if !bytes.Contains(orig, needle) {
return nil
}
updated, changed := rewrite(orig)
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
}
linkNeedle := []byte("[[" + oldPath)
walkErr := walkIndexFiles(h.root, func(fsPath string) error {
return rewriteInto(fsPath, linkNeedle, func(b []byte) ([]byte, bool) {
return rewriteWikiLinks(b, oldPath, newPath)
})
})
if walkErr == nil {
// Canvases reference pages too: as [[wikilinks]] inside text nodes
// and as the path of a file node. Skipping them would leave a move
// silently breaking every canvas that pointed at the moved page —
// the failure mode an auto-rewriting link system exists to prevent.
// The needle is the bare path because a canvas holds it unbracketed.
walkErr = walkCanvasFiles(h.root, func(fsPath string) error {
return rewriteInto(fsPath, []byte(oldPath), func(b []byte) ([]byte, bool) {
return rewriteCanvasLinks(b, oldPath, newPath)
})
})
}
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 parent := filepath.Dir(dstFsPath); parent != "" {
if err := os.MkdirAll(parent, 0755); err != nil { if err := os.MkdirAll(parent, 0755); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
} }
// Phase 2: move the source into place. A plain move renames the whole // Phase 3: move the source into place. A plain move renames the whole
// folder; a merge moves the source's entries into the existing // folder; a merge moves the source's entries into the existing
// destination and drops the emptied source. // destination and drops the emptied source.
if merging { if merging {
if err := mergeFolder(srcFsPath, dstFsPath); err != nil { if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} else { } else {
if err := os.Rename(srcFsPath, dstFsPath); err != nil { if err := os.Rename(srcFsPath, dstFsPath); err != nil {
rollbackRewrites(rewritten)
http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/")) folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
} }
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther) http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
} }
// canMergeMove reports whether moving onto an existing destination should // canMergeMove reports whether moving onto an existing destination should
@@ -206,6 +272,160 @@ func validateAndNormalizeNewPath(raw string) (string, error) {
return "/" + trimmed, nil 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
}
newTarget, ok := rewriteMovedPath(strings.TrimSpace(string(parts[1])), oldPath, newPath)
if !ok {
return match
}
changed = true
suffix := ""
if len(parts[2]) > 0 {
suffix = "::" + string(parts[2])
}
return []byte("[[" + newTarget + suffix + "]]")
})
return out, changed
}
// rewriteMovedPath maps a wiki path onto its position after a move, matching
// either the moved path itself or anything beneath it. ok=false means the path
// is unaffected. Shared by the wiki-link rewriter and the canvas rewriter so
// both agree on what "under the moved page" means.
func rewriteMovedPath(target, oldPath, newPath string) (string, bool) {
norm := normalizeMovePath(target)
switch {
case norm == oldPath:
return newPath, true
case strings.HasPrefix(norm, oldPath+"/"):
return newPath + strings.TrimPrefix(norm, oldPath), true
}
return "", false
}
// rewriteCanvasLinks updates every reference to a moved page inside a canvas:
// the [[wikilinks]] in text nodes, the target of a file node, and a group's
// background image. An unparseable canvas is left exactly as it is rather than
// half-rewritten — a move is not the moment to start editing a broken file.
//
// The result is re-marshalled from the parsed document, so it is normalised the
// same way a save through the editor would normalise it.
func rewriteCanvasLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
doc, err := parseCanvas(content)
if err != nil {
return content, false
}
changed := false
for i := range doc.Nodes {
n := &doc.Nodes[i]
switch n.Type {
case canvasNodeText:
if updated, ok := rewriteWikiLinks([]byte(n.Text), oldPath, newPath); ok {
n.Text = string(updated)
changed = true
}
case canvasNodeFile:
if target, ok := rewriteMovedPath(n.File, oldPath, newPath); ok {
n.File = target
changed = true
}
case canvasNodeGroup:
if n.Background == "" {
continue
}
if target, ok := rewriteMovedPath(n.Background, oldPath, newPath); ok {
n.Background = target
changed = true
}
}
}
if !changed {
return content, false
}
out, err := json.MarshalIndent(doc, "", "\t")
if err != nil {
return content, false
}
return append(out, '\n'), true
}
// 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
}
// walkCanvasFiles visits every `*.canvas` under root, skipping hidden entries
// (which excludes the `.<name>.canvas.bak` backups). Same shape and same
// reasoning as walkIndexFiles: no stat per regular file.
func walkCanvasFiles(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 := walkCanvasFiles(full, visit); err != nil {
return err
}
continue
}
if isCanvasFile(name) {
if err := visit(full); err != nil {
return err
}
}
}
return nil
}
// writeFileAtomic writes data to a temp file in the same directory as path // 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. // and renames it into place so readers never observe a partial file.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error { func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
+5 -5
View File
@@ -19,12 +19,12 @@ import (
var md goldmark.Markdown var md goldmark.Markdown
// initMarkdown builds the package-level goldmark instance. Neither extension // initMarkdown builds the package-level goldmark instance. Called once from
// resolves anything against the filesystem — wiki links are search queries and // main after the wiki root is known so the wiki-link extension can resolve
// embed destinations are relative to the page — so this needs no wiki root. // targets against the filesystem.
func initMarkdown() { func initMarkdown(root string) {
md = goldmark.New( md = goldmark.New(
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(), newEmbedExt()), goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newWikiEmbedExt(root)),
goldmark.WithParserOptions(parser.WithAutoHeadingID()), goldmark.WithParserOptions(parser.WithAutoHeadingID()),
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()), goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
) )
+2 -7
View File
@@ -96,14 +96,9 @@ func (h *handler) handleThumb(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad path", http.StatusBadRequest) http.Error(w, "bad path", http.StatusBadRequest)
return return
} }
h.serveThumbnail(w, r, filepath.Join(h.root, filepath.FromSlash(path.Clean(decoded)))) cleanPath := path.Clean(decoded)
}
// serveThumbnail generates (or serves from cache) a thumbnail of the file at srcFS := filepath.Join(h.root, filepath.FromSlash(cleanPath))
// 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 <img src> stays relative to the page.
func (h *handler) serveThumbnail(w http.ResponseWriter, r *http.Request, srcFS string) {
rel, err := filepath.Rel(h.root, srcFS) rel, err := filepath.Rel(h.root, srcFS)
if err != nil || strings.HasPrefix(rel, "..") { if err != nil || strings.HasPrefix(rel, "..") {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
+115 -47
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"bytes" "bytes"
"net/url" "net/url"
"os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -14,17 +16,20 @@ import (
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
) )
// wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of // wikiLinkRe matches [[target]] and [[target::display]] anchored at the start
// the current inline reader. Query and display forbid newlines and brackets; // of the current inline reader. Target and display forbid newlines and
// the query is non-greedy so the first `::` separates query from display when // brackets; the target is non-greedy so the first `::` separates target from
// both are present. // display when both are present.
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`) var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already // wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
// normalized by wikiSearchQuery. // 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 { type wikiLinkNode struct {
ast.BaseInline ast.BaseInline
Query []byte Target []byte
Display []byte Display []byte
} }
@@ -34,32 +39,27 @@ func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink }
func (n *wikiLinkNode) Dump(source []byte, level int) { func (n *wikiLinkNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{ ast.DumpHelper(n, source, level, map[string]string{
"Query": string(n.Query), "Target": string(n.Target),
"Display": string(n.Display), "Display": string(n.Display),
}, nil) }, nil)
} }
// wikiSearchQuery normalizes the raw text inside [[...]] into a search query. // isValidWikiTarget rejects targets that are not absolute or that contain
// A leading `/` marks a link written under the old path-based syntax; those // traversal / empty segments. Matches the validation used by the move endpoint.
// collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so func isValidWikiTarget(target []byte) bool {
// existing content keeps resolving without a rewrite. Everything else is free if len(target) == 0 || target[0] != '/' {
// text and passes through with only surrounding space trimmed. return false
func wikiSearchQuery(raw string) string {
q := strings.TrimSpace(raw)
if !strings.HasPrefix(q, "/") {
return q
} }
trimmed := strings.Trim(q, "/") trimmed := strings.Trim(string(target), "/")
if i := strings.LastIndex(trimmed, "/"); i >= 0 { if trimmed == "" {
return strings.TrimSpace(trimmed[i+1:]) return true // root link
} }
return trimmed for _, seg := range strings.Split(trimmed, "/") {
if seg == "" || seg == "." || seg == ".." {
return false
} }
}
// wikiSearchHref builds the search-page URL for a query. Escaping leaves only return true
// 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{} type wikiLinkParser struct{}
@@ -75,10 +75,8 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
if m == nil { if m == nil {
return nil return nil
} }
// A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is target := bytes.TrimSpace(line[m[2]:m[3]])
// not a link; leave the source text alone. if !isValidWikiTarget(target) {
query := wikiSearchQuery(string(line[m[2]:m[3]]))
if query == "" {
return nil return nil
} }
var display []byte var display []byte
@@ -87,12 +85,76 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
} }
block.Advance(m[1]) block.Advance(m[1])
return &wikiLinkNode{ return &wikiLinkNode{
Query: []byte(query), Target: append([]byte(nil), target...),
Display: append([]byte(nil), display...), Display: append([]byte(nil), display...),
} }
} }
type wikiLinkRenderer struct{} // 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
}
func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiLink, r.render) reg.Register(kindWikiLink, r.render)
@@ -103,32 +165,38 @@ func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
n := node.(*wikiLinkNode) n := node.(*wikiLinkNode)
writeWikiSearchLink(w, string(n.Query), string(n.Display)) writeWikiLink(w, r.root, string(n.Target), string(n.Display))
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
// writeWikiSearchLink renders the anchor for a [[query]] link: an href into the // writeWikiLink renders a wiki-link anchor for target with an optional display
// search page, labelled with the display override or the query itself. Nothing // override. An empty display falls back to the target's last segment; a target
// is resolved at render time — whether the query matches anything is the search // that does not resolve on disk gets the `broken` class. Shared by the wiki-link
// page's business, so there is no broken state here. // renderer and the embed renderer's link fallback.
func writeWikiSearchLink(w util.BufWriter, query, display string) { func writeWikiLink(w util.BufWriter, root, target, display string) {
href := wikiTargetHref(target)
if display == "" { if display == "" {
display = query display = wikiDefaultDisplay(target)
} }
broken := !wikiTargetExists(root, target)
w.WriteString(`<a href="`) w.WriteString(`<a href="`)
w.WriteString(wikiSearchHref(query)) w.WriteString(href)
w.WriteString(`">`) w.WriteString(`"`)
if broken {
w.WriteString(` class="broken"`)
}
w.WriteString(`>`)
w.Write(util.EscapeHTML([]byte(display))) w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`) w.WriteString(`</a>`)
} }
type wikiLinkExt struct{} type wikiLinkExt struct{ root string }
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into // newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
// links to the search page. It needs no wiki root — a query is never resolved // links resolved against root.
// against the filesystem. func newWikiLinkExt(root string) goldmark.Extender {
func newWikiLinkExt() goldmark.Extender { return &wikiLinkExt{root: root}
return &wikiLinkExt{}
} }
func (e *wikiLinkExt) Extend(m goldmark.Markdown) { func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
@@ -138,6 +206,6 @@ func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
util.Prioritized(&wikiLinkParser{}, 199), util.Prioritized(&wikiLinkParser{}, 199),
)) ))
m.Renderer().AddOptions(renderer.WithNodeRenderers( m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiLinkRenderer{}, 500), util.Prioritized(&wikiLinkRenderer{root: e.root}, 500),
)) ))
} }