Rework wikilinks feature
This commit is contained in:
@@ -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 `[[Page Name]]` syntax. When a page is renamed or moved, all wikilinks pointing to it are rewritten automatically to reflect the new path.
|
- **Wikilinks** link between pages with `[[search query]]` syntax, or `[[query::display text]]` to override the label. The link opens the search page for that query, so it keeps resolving after a page is renamed or moved. Files embed with standard Markdown image syntax — `` — relative to the page they sit on.
|
||||||
|
|
||||||
- **Movie import** import movie entries via the OMDb API. Fetches title, year, runtime, genre, director, cast, plot, and poster, and pre-fills a new page with that metadata.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,11 @@
|
|||||||
<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)">L▾</button>
|
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">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">
|
||||||
|
|||||||
+102
-36
@@ -223,6 +223,7 @@
|
|||||||
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('- [ ] '); },
|
||||||
@@ -240,43 +241,34 @@
|
|||||||
movie: function () { M.run(movieCtx); },
|
movie: function () { M.run(movieCtx); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// isValidWikiTarget mirrors the Go validator in wikilinks.go — absolute
|
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
|
||||||
// path, no empty/dot segments. Used to gate the modal's INSERT button.
|
// plus an optional display-text field, inserting [[query]] or
|
||||||
function isValidWikiTarget(p) {
|
// [[query::display]] at the cursor. Picking a suggestion only fills in that
|
||||||
if (!p || p[0] !== '/') return false;
|
// page's name — the link resolves through search at click time, so a path
|
||||||
var trimmed = p.replace(/^\/+|\/+$/g, '');
|
// never enters the document. Inline `[[` typing uses the same suggestions
|
||||||
if (trimmed === '') return true;
|
// via wikicomplete.js.
|
||||||
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);
|
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||||
|
|
||||||
var container = document.createElement('div');
|
var container = document.createElement('div');
|
||||||
|
|
||||||
var targetWrap = document.createElement('div');
|
var queryWrap = document.createElement('div');
|
||||||
var targetInput = document.createElement('input');
|
var queryInput = document.createElement('input');
|
||||||
targetInput.type = 'text';
|
queryInput.type = 'text';
|
||||||
targetInput.className = 'input';
|
queryInput.className = 'input';
|
||||||
targetInput.placeholder = 'Page path or search…';
|
queryInput.placeholder = 'Search query';
|
||||||
targetWrap.appendChild(targetInput);
|
// 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');
|
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(targetWrap);
|
container.appendChild(queryWrap);
|
||||||
container.appendChild(displayInput);
|
container.appendChild(displayInput);
|
||||||
|
|
||||||
var handle = openModal({
|
var handle = openModal({
|
||||||
@@ -284,30 +276,104 @@
|
|||||||
body: container,
|
body: container,
|
||||||
confirm: {
|
confirm: {
|
||||||
label: 'INSERT',
|
label: 'INSERT',
|
||||||
initiallyDisabled: true,
|
initiallyDisabled: !selectedText,
|
||||||
onConfirm: function () {
|
onConfirm: function () {
|
||||||
var target = targetInput.value.trim();
|
var query = queryInput.value.trim();
|
||||||
if (!isValidWikiTarget(target)) return;
|
if (!query) return;
|
||||||
var display = displayInput.value.trim();
|
var display = displayInput.value.trim();
|
||||||
handle.close();
|
handle.close();
|
||||||
insertAtCursor(display ? '[[' + target + '::' + display + ']]' : '[[' + target + ']]');
|
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateConfirm() {
|
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,
|
showFooter: false,
|
||||||
container: targetWrap,
|
container: queryWrap,
|
||||||
onPick: function (r) {
|
onPick: function (r) {
|
||||||
targetInput.value = '/' + r.path;
|
queryInput.value = r.name;
|
||||||
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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// Wiki links are search queries, so the popup is the same typeahead the header
|
||||||
// fires only once the `[[` token's content begins with `/` (targets are
|
// search box uses: the text typed after `[[` goes to `/_search` and each match
|
||||||
// absolute; free-text search lives in the toolbar modal instead). The content
|
// is offered by name. Picking one replaces the query segment with that page's
|
||||||
// is split into a parent path (up to and including the last `/`) and a partial
|
// name — never a path — so what lands in the document is still a query. The
|
||||||
// segment (the text after it); the parent's children are fetched from the
|
// trailing `]]` and any `::display` tail are left untouched.
|
||||||
// existing `?tree=1` endpoint and filtered to names containing the partial
|
|
||||||
// (case-insensitive substring).
|
|
||||||
//
|
//
|
||||||
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
|
// Exposes window.WikiComplete.source for main.js to register via CM's
|
||||||
// deeper; picking a file inserts `<name>` and stops. Only the current segment
|
// autocompletion().
|
||||||
// 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 () {
|
||||||
// treeURL builds the `?tree=1` request URL for an absolute parent path,
|
// Matches search-suggest.js so both surfaces fire at the same point.
|
||||||
// percent-encoding each segment. A leading/trailing slash is tolerated;
|
var MIN_QUERY_LEN = 2;
|
||||||
// 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 fetchTree(parent) {
|
function fetchSuggestions(query) {
|
||||||
return fetch(treeURL(parent), {
|
return fetch('/_search?q=' + encodeURIComponent(query), {
|
||||||
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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// makeApply builds the apply() for a chosen entry. It replaces only the
|
// queryEnd returns where the current query segment ends: the first `]`, the
|
||||||
// current segment ([from, to]); the trailing `]]` is untouched, so the
|
// `::` that starts the display text, or end of line. Replacing only up to
|
||||||
// cursor ends up parked before it. Folders append `/` and re-open the popup
|
// there keeps a re-edit from eating an existing alias or the closing `]]`.
|
||||||
// to drill into the next level; files terminate.
|
function queryEnd(context, pos) {
|
||||||
function makeApply(name, kind) {
|
var line = context.state.doc.lineAt(pos);
|
||||||
return function (view, completion, from, to) {
|
var to = pos;
|
||||||
var isFolder = kind === 'folder';
|
while (to < line.to) {
|
||||||
var insert = isFolder ? name + '/' : name;
|
var ch = context.state.sliceDoc(to, to + 1);
|
||||||
view.dispatch({
|
if (ch === ']') break;
|
||||||
changes: { from: from, to: to, insert: insert },
|
if (ch === ':' && context.state.sliceDoc(to, to + 2) === '::') break;
|
||||||
selection: { anchor: from + insert.length },
|
to++;
|
||||||
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 when `[[` is followed by content that
|
// CM completion source. Activates on `[[` followed by at least
|
||||||
// begins with `/`. The content is split at its last `/` into a parent path
|
// MIN_QUERY_LEN characters; a cursor past a `::` is in the display tail and
|
||||||
// and a partial segment; the parent's children are fetched, filtered to
|
// gets nothing.
|
||||||
// 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[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 from = match.from + 2;
|
||||||
var parent = content.slice(0, lastSlash + 1);
|
var to = queryEnd(context, context.pos);
|
||||||
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; }
|
||||||
fetchTree(parent).then(function (resp) {
|
fetchSuggestions(query).then(function (resp) {
|
||||||
if (context.aborted || !resp) { resolve(null); return; }
|
if (context.aborted) { resolve(null); return; }
|
||||||
var needle = partial.toLowerCase();
|
var options = (resp.results || []).map(function (r) {
|
||||||
var options = (resp.entries || []).reduce(function (acc, e) {
|
return { label: r.name, detail: '/' + r.path, apply: r.name };
|
||||||
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;
|
// filter:false — the server already ranked these fuzzily, and
|
||||||
}, []);
|
// CM's own prefix filter would drop the near-misses (typos,
|
||||||
// No validFor: the source re-runs on each keystroke, so every
|
// token reorderings) that make the fuzzy search worth having.
|
||||||
// edit (more chars, backspace, or a `/` that drills into the
|
// No validFor either: every keystroke re-queries from scratch.
|
||||||
// next folder) re-fetches and re-filters from scratch.
|
resolve({ from: from, to: to, options: options, filter: false });
|
||||||
resolve({ from: from, to: to, options: options });
|
|
||||||
}).catch(function () {
|
}).catch(function () {
|
||||||
resolve(null);
|
resolve(null);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-16
@@ -129,23 +129,9 @@ 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: body,
|
body: input,
|
||||||
confirm: {
|
confirm: {
|
||||||
label: 'MOVE',
|
label: 'MOVE',
|
||||||
onConfirm: function () {
|
onConfirm: function () {
|
||||||
@@ -154,7 +140,6 @@ 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);
|
||||||
|
|||||||
@@ -75,8 +75,6 @@ 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; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
@@ -14,105 +14,95 @@ import (
|
|||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
|
// Embeds are ordinary Markdown images: .
|
||||||
// The inner blob forbids brackets and newlines but allows ':' so the whole
|
//
|
||||||
// target::caption::align payload travels as one capture — the caption and
|
// Destinations are relative to the page they sit on. Pages always render at a
|
||||||
// alignment tail is split apart in Go (parseEmbedFields). This mirrors
|
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
|
||||||
// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
|
// resolves the destination and nothing here builds a URL or touches the
|
||||||
var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
|
// filesystem. Goldmark's own image parser produces the nodes; only rendering
|
||||||
|
// 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
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiEmbedNode is the AST node produced by wikiEmbedParser.
|
// embedAlign maps an image title to its float class. A title that is not an
|
||||||
type wikiEmbedNode struct {
|
// alignment keyword is ignored and the embed floats right.
|
||||||
ast.BaseInline
|
func embedAlign(title []byte) string {
|
||||||
Target string
|
switch strings.TrimSpace(string(title)) {
|
||||||
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, true
|
return alignLeft
|
||||||
case alignRight:
|
|
||||||
return alignRight, true
|
|
||||||
case alignCenter:
|
case alignCenter:
|
||||||
return alignCenter, true
|
return alignCenter
|
||||||
}
|
}
|
||||||
return "", false
|
return alignRight
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseEmbedFields splits the inner ![[...]] blob into target, caption, and
|
// embedIsImage reports whether a destination names an image, i.e. whether it
|
||||||
// alignment per the embed syntax rules. The target is the field before the
|
// renders as a <figure> rather than degrading to a link. Extension-only — no
|
||||||
// first "::". The trailing "::"-fields are interpreted as:
|
// stat, so a destination gone stale simply 404s when clicked.
|
||||||
// - none: no caption, default alignment (right)
|
func embedIsImage(dest []byte) bool {
|
||||||
// - one keyword field: that alignment, no caption
|
return isImageFile(path.Base(string(dest)))
|
||||||
// - 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
|
type embedRenderer struct{}
|
||||||
// caption may contain a literal "::" as long as an alignment field trails it.
|
|
||||||
func parseEmbedFields(inner string) (target, caption, align string) {
|
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||||
fields := strings.Split(inner, "::")
|
reg.Register(ast.KindImage, r.render)
|
||||||
target = strings.TrimSpace(fields[0])
|
}
|
||||||
tail := fields[1:]
|
|
||||||
align = alignRight
|
// render writes a <figure> holding a thumbnail that links to the full file,
|
||||||
switch len(tail) {
|
// with the image's inline children as <figcaption>. Children are rendered by
|
||||||
case 0:
|
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
|
||||||
// target only
|
// emphasis or a link. A non-image destination renders as a plain anchor
|
||||||
case 1:
|
// labelled with the caption, or with the file name when there is none.
|
||||||
if kw, ok := alignKeyword(tail[0]); ok {
|
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
align = kw
|
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(`<a href="`)
|
||||||
|
w.Write(dest)
|
||||||
|
w.WriteString(`">`)
|
||||||
|
if !captioned {
|
||||||
|
w.Write(util.EscapeHTML([]byte(name)))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
caption = strings.TrimSpace(tail[0])
|
w.WriteString(`</a>`)
|
||||||
}
|
}
|
||||||
default:
|
return ast.WalkContinue, nil
|
||||||
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
|
|
||||||
align = kw
|
|
||||||
}
|
}
|
||||||
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
|
|
||||||
}
|
|
||||||
return target, caption, align
|
|
||||||
}
|
|
||||||
|
|
||||||
type wikiEmbedParser struct{}
|
if entering {
|
||||||
|
w.WriteString(`<figure class="embed embed-`)
|
||||||
func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
|
w.WriteString(embedAlign(n.Title))
|
||||||
|
w.WriteString(`"><a class="embed-link" href="`)
|
||||||
func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
w.Write(dest)
|
||||||
line, _ := block.PeekLine()
|
w.WriteString(`"><img src="`)
|
||||||
// Require the exact "")
|
w.Write(dest)
|
||||||
// falls through to goldmark's default image parser at priority 200.
|
w.WriteString(`?w=`)
|
||||||
if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
|
w.WriteString(strconv.Itoa(embedThumbW))
|
||||||
return nil
|
w.WriteString(`" alt="`)
|
||||||
|
w.Write(util.EscapeHTML([]byte(name)))
|
||||||
|
w.WriteString(`"></a>`)
|
||||||
|
if captioned {
|
||||||
|
w.WriteString(`<figcaption>`)
|
||||||
}
|
}
|
||||||
m := wikiEmbedRe.FindSubmatchIndex(line)
|
return ast.WalkContinue, nil
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
|
if captioned {
|
||||||
if !isValidWikiTarget([]byte(target)) {
|
w.WriteString(`</figcaption>`)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
block.Advance(m[1])
|
w.WriteString(`</figure>`)
|
||||||
return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
|
// 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
|
// 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{ root string }
|
type embedBlockTransformer struct{}
|
||||||
|
|
||||||
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()
|
||||||
@@ -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.
|
// 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.(*wikiEmbedNode); ok {
|
if _, ok := c.(*ast.Image); ok {
|
||||||
parent.InsertBefore(parent, p, c)
|
parent.InsertBefore(parent, p, c)
|
||||||
}
|
}
|
||||||
c = next
|
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
|
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
|
||||||
// embed and nothing else visible — only such embeds and whitespace/line-break
|
// 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 embed
|
// text. Only these are safe to dissolve: a paragraph carrying prose, or an
|
||||||
// that degrades to an inline link (missing / non-image target), stays wrapped so
|
// image that degrades to an inline link (non-image destination), stays wrapped
|
||||||
// the fallback anchor keeps its paragraph.
|
// so 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 *wikiEmbedNode:
|
case *ast.Image:
|
||||||
if !embedIsImage(t.root, n.Target) {
|
if !embedIsImage(n.Destination) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
hasFigure = true
|
hasFigure = true
|
||||||
@@ -178,77 +168,19 @@ func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []b
|
|||||||
return hasFigure
|
return hasFigure
|
||||||
}
|
}
|
||||||
|
|
||||||
// embedIsImage reports whether target resolves to an existing image file, i.e.
|
type embedExt struct{}
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
type wikiEmbedRenderer struct {
|
// newEmbedExt returns a goldmark extension that renders Markdown images as
|
||||||
root string
|
// 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) {
|
func (e *embedExt) Extend(m goldmark.Markdown) {
|
||||||
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{root: e.root}, 100),
|
util.Prioritized(embedBlockTransformer{}, 100),
|
||||||
))
|
))
|
||||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||||
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
|
util.Prioritized(&embedRenderer{}, 500),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -105,7 +106,7 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
initMarkdown(root)
|
initMarkdown()
|
||||||
|
|
||||||
authKey, err := loadOrCreateAuthKey(root)
|
authKey, err := loadOrCreateAuthKey(root)
|
||||||
if err != nil {
|
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
|
// 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, r.URL.Path+"/", http.StatusMovedPermanently)
|
http.Redirect(w, r, slashedURL(r.URL), http.StatusMovedPermanently)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.serveDir(w, r, urlPath, fsPath)
|
h.serveDir(w, r, urlPath, fsPath)
|
||||||
@@ -232,14 +233,39 @@ 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"]
|
||||||
|
|
||||||
@@ -373,7 +399,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("links"), query.Has("merge"))
|
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if query.Has("toggle") {
|
if query.Has("toggle") {
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"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. When
|
// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL.
|
||||||
// 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
|
||||||
@@ -21,7 +16,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, updateLinks, merge bool) {
|
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, 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)
|
||||||
@@ -74,69 +69,32 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
merging = true
|
merging = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1: optionally walk the tree and rewrite every index.md that
|
// Phase 1: create intermediate parent folders for the destination.
|
||||||
// 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.
|
|
||||||
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 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
|
// 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, wikiTargetHref(newPath), http.StatusSeeOther)
|
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// canMergeMove reports whether moving onto an existing destination should
|
// canMergeMove reports whether moving onto an existing destination should
|
||||||
@@ -248,77 +206,6 @@ 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
|
|
||||||
}
|
|
||||||
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
|
// 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 {
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import (
|
|||||||
|
|
||||||
var md goldmark.Markdown
|
var md goldmark.Markdown
|
||||||
|
|
||||||
// initMarkdown builds the package-level goldmark instance. Called once from
|
// initMarkdown builds the package-level goldmark instance. Neither extension
|
||||||
// main after the wiki root is known so the wiki-link extension can resolve
|
// resolves anything against the filesystem — wiki links are search queries and
|
||||||
// targets against the filesystem.
|
// embed destinations are relative to the page — so this needs no wiki root.
|
||||||
func initMarkdown(root string) {
|
func initMarkdown() {
|
||||||
md = goldmark.New(
|
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.WithParserOptions(parser.WithAutoHeadingID()),
|
||||||
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
|
goldmark.WithRendererOptions(html.WithUnsafe(), html.WithHardWraps()),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -96,9 +96,14 @@ 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
|
||||||
}
|
}
|
||||||
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 <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)
|
||||||
|
|||||||
+48
-116
@@ -3,8 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -16,20 +14,17 @@ import (
|
|||||||
"github.com/yuin/goldmark/util"
|
"github.com/yuin/goldmark/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wikiLinkRe matches [[target]] and [[target::display]] anchored at the start
|
// wikiLinkRe matches [[query]] and [[query::display]] anchored at the start of
|
||||||
// of the current inline reader. Target and display forbid newlines and
|
// the current inline reader. Query and display forbid newlines and brackets;
|
||||||
// brackets; the target is non-greedy so the first `::` separates target from
|
// the query is non-greedy so the first `::` separates query from display when
|
||||||
// display when both are present.
|
// both are present.
|
||||||
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
||||||
|
|
||||||
// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source.
|
// wikiLinkNode is the AST node produced by wikiLinkParser. Query is already
|
||||||
// Used by the move-endpoint rewriter; not by the goldmark parser.
|
// normalized by wikiSearchQuery.
|
||||||
var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n]+?)(?:::([^\[\]\n]+))?\]\]`)
|
|
||||||
|
|
||||||
// wikiLinkNode is the AST node produced by wikiLinkParser.
|
|
||||||
type wikiLinkNode struct {
|
type wikiLinkNode struct {
|
||||||
ast.BaseInline
|
ast.BaseInline
|
||||||
Target []byte
|
Query []byte
|
||||||
Display []byte
|
Display []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,27 +34,32 @@ 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{
|
||||||
"Target": string(n.Target),
|
"Query": string(n.Query),
|
||||||
"Display": string(n.Display),
|
"Display": string(n.Display),
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidWikiTarget rejects targets that are not absolute or that contain
|
// wikiSearchQuery normalizes the raw text inside [[...]] into a search query.
|
||||||
// traversal / empty segments. Matches the validation used by the move endpoint.
|
// A leading `/` marks a link written under the old path-based syntax; those
|
||||||
func isValidWikiTarget(target []byte) bool {
|
// collapse to their last segment ("/Trips/Japan 2024" -> "Japan 2024") so
|
||||||
if len(target) == 0 || target[0] != '/' {
|
// existing content keeps resolving without a rewrite. Everything else is free
|
||||||
return false
|
// 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), "/")
|
trimmed := strings.Trim(q, "/")
|
||||||
if trimmed == "" {
|
if i := strings.LastIndex(trimmed, "/"); i >= 0 {
|
||||||
return true // root link
|
return strings.TrimSpace(trimmed[i+1:])
|
||||||
}
|
}
|
||||||
for _, seg := range strings.Split(trimmed, "/") {
|
return trimmed
|
||||||
if seg == "" || seg == "." || seg == ".." {
|
}
|
||||||
return false
|
|
||||||
}
|
// 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.
|
||||||
return true
|
func wikiSearchHref(query string) string {
|
||||||
|
return "/?q=" + url.QueryEscape(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
type wikiLinkParser struct{}
|
type wikiLinkParser struct{}
|
||||||
@@ -75,8 +75,10 @@ func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Con
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
target := bytes.TrimSpace(line[m[2]:m[3]])
|
// A query that normalizes to nothing (e.g. a legacy "[[/]]" root link) is
|
||||||
if !isValidWikiTarget(target) {
|
// not a link; leave the source text alone.
|
||||||
|
query := wikiSearchQuery(string(line[m[2]:m[3]]))
|
||||||
|
if query == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var display []byte
|
var display []byte
|
||||||
@@ -85,76 +87,12 @@ 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{
|
||||||
Target: append([]byte(nil), target...),
|
Query: []byte(query),
|
||||||
Display: append([]byte(nil), display...),
|
Display: append([]byte(nil), display...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and
|
type wikiLinkRenderer struct{}
|
||||||
// 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)
|
||||||
@@ -165,38 +103,32 @@ 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)
|
||||||
writeWikiLink(w, r.root, string(n.Target), string(n.Display))
|
writeWikiSearchLink(w, string(n.Query), string(n.Display))
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeWikiLink renders a wiki-link anchor for target with an optional display
|
// writeWikiSearchLink renders the anchor for a [[query]] link: an href into the
|
||||||
// override. An empty display falls back to the target's last segment; a target
|
// search page, labelled with the display override or the query itself. Nothing
|
||||||
// that does not resolve on disk gets the `broken` class. Shared by the wiki-link
|
// is resolved at render time — whether the query matches anything is the search
|
||||||
// renderer and the embed renderer's link fallback.
|
// page's business, so there is no broken state here.
|
||||||
func writeWikiLink(w util.BufWriter, root, target, display string) {
|
func writeWikiSearchLink(w util.BufWriter, query, display string) {
|
||||||
href := wikiTargetHref(target)
|
|
||||||
if display == "" {
|
if display == "" {
|
||||||
display = wikiDefaultDisplay(target)
|
display = query
|
||||||
}
|
}
|
||||||
broken := !wikiTargetExists(root, target)
|
|
||||||
|
|
||||||
w.WriteString(`<a href="`)
|
w.WriteString(`<a href="`)
|
||||||
w.WriteString(href)
|
w.WriteString(wikiSearchHref(query))
|
||||||
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{ root string }
|
type wikiLinkExt struct{}
|
||||||
|
|
||||||
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
|
// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into
|
||||||
// links resolved against root.
|
// links to the search page. It needs no wiki root — a query is never resolved
|
||||||
func newWikiLinkExt(root string) goldmark.Extender {
|
// against the filesystem.
|
||||||
return &wikiLinkExt{root: root}
|
func newWikiLinkExt() goldmark.Extender {
|
||||||
|
return &wikiLinkExt{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
func (e *wikiLinkExt) Extend(m goldmark.Markdown) {
|
||||||
@@ -206,6 +138,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{root: e.root}, 500),
|
util.Prioritized(&wikiLinkRenderer{}, 500),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user