Improve wiki linking

This commit is contained in:
2026-06-09 18:36:40 +02:00
parent 5525a03179
commit 204e89dbce
3 changed files with 160 additions and 78 deletions
+88 -66
View File
@@ -1,93 +1,115 @@
// wikicomplete.js — the `[[` wikilink autocomplete source for CodeMirror.
//
// Triggers when the cursor sits in a `[[…` token (freshly typed or re-edited
// inside an existing `[[…]]`), queries the existing /_search JSON endpoint
// (debounced), and offers matching page paths. Selecting a result inserts the
// absolute path and auto-closes `]]`, leaving the cursor before the close so a
// `::display` alias can be typed (decision 7). Exposes window.WikiComplete.source
// A level-by-level folder/file browser scoped to the path typed so far. It
// fires only once the `[[` token's content begins with `/` (targets are
// absolute; free-text search lives in the toolbar modal instead). The content
// is split into a parent path (up to and including the last `/`) and a partial
// segment (the text after it); the parent's children are fetched from the
// existing `?tree=1` endpoint and filtered to names containing the partial
// (case-insensitive substring).
//
// Picking a folder inserts `<name>/` and re-opens the popup to drill one level
// 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 () {
var DEBOUNCE_MS = 100;
var MIN_QUERY_LEN = 2;
// Mirrors the Go validator (wikilinks.go isValidWikiTarget) and the old
// client check: absolute path, no empty/'.'/'..' segments. Server results
// are wiki paths so this is mostly a guard against odd index entries.
function isValidWikiTarget(p) {
if (!p || p[0] !== '/') return false;
var trimmed = p.replace(/^\/+|\/+$/g, '');
if (trimmed === '') return true;
var segs = trimmed.split('/');
for (var i = 0; i < segs.length; i++) {
if (segs[i] === '' || segs[i] === '.' || segs[i] === '..') return false;
}
return true;
// treeURL builds the `?tree=1` request URL for an absolute parent path,
// percent-encoding each segment. A leading/trailing slash is tolerated;
// root resolves to `/?tree=1`.
function treeURL(parent) {
var trimmed = parent.replace(/^\/+/, '').replace(/\/+$/, '');
if (trimmed === '') return '/?tree=1';
var enc = trimmed.split('/').map(encodeURIComponent).join('/');
return '/' + enc + '/?tree=1';
}
// Build the apply() for a chosen result. Inserts the path over the typed
// query and ensures a single trailing `]]`, cursor parked before it. If the
// user (or closeBrackets) already produced `]]`, we don't double it up.
function makeApply(path) {
return function (view, completion, from, to) {
var closed = view.state.sliceDoc(to, to + 2) === ']]';
var insert = closed ? path : path + ']]';
view.dispatch({
changes: { from: from, to: to, insert: insert },
selection: { anchor: from + path.length },
scrollIntoView: true,
});
};
}
function fetchSuggest(query) {
return fetch('/_search?q=' + encodeURIComponent(query), {
function fetchTree(parent) {
return fetch(treeURL(parent), {
credentials: 'same-origin',
headers: { 'Accept': 'application/json' },
}).then(function (r) {
// A 404 means the parent folder doesn't exist (typo, or a path under
// a file) — treat it as "no completions", not an error.
if (r.status === 404) return null;
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
}
// CM completion source. `[[` plus any run of non-`]`, non-newline chars
// before the cursor is the trigger; the query is the text after `[[`. The
// replaced range extends past the cursor to the end of the inner token (up
// to `]]`/EOL) so re-editing inside an existing `[[…]]` replaces the whole
// target instead of duplicating the trailing text.
// makeApply builds the apply() for a chosen entry. It replaces only the
// current segment ([from, to]); the trailing `]]` is untouched, so the
// cursor ends up parked before it. Folders append `/` and re-open the popup
// to drill into the next level; files terminate.
function makeApply(name, kind) {
return function (view, completion, from, to) {
var isFolder = kind === 'folder';
var insert = isFolder ? name + '/' : name;
view.dispatch({
changes: { from: from, to: to, insert: insert },
selection: { anchor: from + insert.length },
scrollIntoView: true,
});
if (isFolder) {
// Re-open after the transaction so the completion plugin sees
// the updated document (the next level's parent path).
setTimeout(function () { CM.startCompletion(view); }, 0);
}
};
}
// CM completion source. Activates when `[[` is followed by content that
// begins with `/`. The content is split at its last `/` into a parent path
// and a partial segment; the parent's children are fetched, filtered to
// names containing the partial (case-insensitive substring), and offered
// with name-only labels.
function source(context) {
var match = context.matchBefore(/\[\[[^\]\n]*/);
if (!match) return null;
var query = context.state.sliceDoc(match.from + 2, context.pos);
if (query.length < MIN_QUERY_LEN && !context.explicit) return null;
var content = context.state.sliceDoc(match.from + 2, context.pos);
if (content[0] !== '/') return null;
var lastSlash = content.lastIndexOf('/');
var parent = content.slice(0, lastSlash + 1);
var partial = content.slice(lastSlash + 1);
// Replace only the current segment: from the start of the partial up to
// the next `/` or `]` (or end of line). This narrows re-edits inside an
// existing `[[…]]` so drilling doesn't duplicate trailing text.
var from = match.from + 2 + lastSlash + 1;
var line = context.state.doc.lineAt(context.pos);
var to = context.pos;
while (to < line.to && context.state.sliceDoc(to, to + 1) !== ']') to++;
while (to < line.to) {
var ch = context.state.sliceDoc(to, to + 1);
if (ch === '/' || ch === ']') break;
to++;
}
return new Promise(function (resolve) {
var timer = setTimeout(function () {
if (context.aborted) { resolve(null); return; }
fetchSuggest(query).then(function (resp) {
if (context.aborted) { resolve(null); return; }
var options = (resp.results || []).reduce(function (acc, r) {
var path = '/' + r.path;
if (isValidWikiTarget(path)) {
acc.push({ label: path, detail: r.name, apply: makeApply(path) });
}
if (context.aborted) { resolve(null); return; }
fetchTree(parent).then(function (resp) {
if (context.aborted || !resp) { resolve(null); return; }
var needle = partial.toLowerCase();
var options = (resp.entries || []).reduce(function (acc, e) {
if (needle && e.name.toLowerCase().indexOf(needle) === -1) {
return acc;
}, []);
resolve({ from: match.from + 2, to: to, options: options });
}).catch(function () {
resolve(null);
});
}, DEBOUNCE_MS);
// CM doesn't cancel pending promises, but it sets context.aborted;
// clear the timer too if the doc moved on before it fired.
if (context.addEventListener) {
context.addEventListener('abort', function () { clearTimeout(timer); });
}
}
acc.push({
label: e.name,
type: e.kind === 'folder' ? 'folder' : 'file',
apply: makeApply(e.name, e.kind),
});
return acc;
}, []);
// No validFor: the source re-runs on each keystroke, so every
// edit (more chars, backspace, or a `/` that drills into the
// next folder) re-fetches and re-filters from scratch.
resolve({ from: from, to: to, options: options });
}).catch(function () {
resolve(null);
});
});
}
return { source: source, isValidWikiTarget: isValidWikiTarget };
return { source: source };
})();