Exact page matches in search

This commit is contained in:
2026-07-20 09:08:35 +02:00
parent 5303645173
commit 34f750beff
3 changed files with 80 additions and 7 deletions
+49
View File
@@ -11,9 +11,58 @@ function rebuildIndex() {
}); });
} }
function encodeSearchPath(p) {
if (p === '/' || p === '') return '/';
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
}
// createSearchPage runs the new-page flow (pick a parent folder, then confirm
// the name) starting from the search query, so a query with no exact page can
// be turned into that page in one step. The name field is pre-filled with the
// query but stays editable.
function createSearchPage(name) {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
openTreePicker({
title: 'New page — where?',
mode: 'folder',
initialPath: current,
preselect: current,
hideFiles: true,
confirmLabel: 'NEXT',
onSelect: function (parentPath) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Page name';
input.value = name || '';
openModal({
title: 'New page — name?',
body: input,
confirm: {
label: 'CREATE',
onConfirm: function () {
var finalName = input.value.trim();
if (!finalName) return;
var base = parentPath === '/' ? '/' : encodeSearchPath(parentPath) + '/';
window.location.href = base + encodeURIComponent(finalName) + '/?edit';
}
}
});
}
});
}
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
wireDropdown(document.querySelector('[data-action="actions-drop"]')); wireDropdown(document.querySelector('[data-action="actions-drop"]'));
var createLink = document.querySelector('[data-create-page]');
if (createLink) {
createLink.addEventListener('click', function (e) {
e.preventDefault();
createSearchPage(createLink.getAttribute('data-create-page'));
});
}
// Focus the search input on results pages so Tab steps directly into the // Focus the search input on results pages so Tab steps directly into the
// first match — the input sits immediately before the results in DOM // first match — the input sits immediately before the results in DOM
// order, so the natural tab sequence is input → first result → next, … // order, so the natural tab sequence is input → first result → next, …
+11 -4
View File
@@ -4,7 +4,17 @@
{{define "content"}} {{define "content"}}
{{if .Query}} {{if .Query}}
{{if or .Pages .Files}} {{if .Exact}}
<h2 class="search-section">Exact Match</h2>
{{range .Exact}}
<article class="search-card">
<a href="{{.URL}}">{{.Name}}</a>
<div class="muted">/{{.Path}}</div>
</article>
{{end}}
{{else}}
<p class="muted">No page named &ldquo;{{.Query}}&rdquo;<button class="btn btn-small" data-create-page="{{.Query}}">create it</button></p>
{{end}}
{{if .Pages}} {{if .Pages}}
<h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2> <h2 class="search-section">Matching Pages <span class="muted">{{.PageTotal}}</span></h2>
{{range .Pages}} {{range .Pages}}
@@ -27,9 +37,6 @@
</article> </article>
{{end}} {{end}}
{{end}} {{end}}
{{else}}
<p class="empty">No matches for &ldquo;{{.Query}}&rdquo;.</p>
{{end}}
{{else}} {{else}}
<p class="empty">Enter a query above.</p> <p class="empty">Enter a query above.</p>
{{end}} {{end}}
+20 -3
View File
@@ -18,6 +18,10 @@ import (
// section header still reports the true total so the user knows more exist. // section header still reports the true total so the user knows more exist.
const searchSectionCap = 10 const searchSectionCap = 10
// exactNameScore is the score scoreName assigns to a whole-name exact match.
// handleSearch peels these into the promoted "Exact Match" section.
const exactNameScore = 1000
type searchResult struct { type searchResult struct {
Name string Name string
URL string URL string
@@ -32,8 +36,11 @@ type searchPageData struct {
Title string Title string
EditMode bool EditMode bool
Query string Query string
Pages []searchResult // Exact holds page(s) whose name equals the query exactly, promoted to
Files []searchResult // their own section above the fuzzy matches.
Exact []searchResult
Pages []searchResult
Files []searchResult
// PageTotal/FileTotal are the true match totals; Pages/Files are capped at // PageTotal/FileTotal are the true match totals; Pages/Files are capped at
// searchSectionCap for rendering. // searchSectionCap for rendering.
PageTotal int PageTotal int
@@ -101,6 +108,15 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
pages, builtAt := searchWiki(query) pages, builtAt := searchWiki(query)
files := searchFiles(query) files := searchFiles(query)
// searchWiki sorts by score desc, so exact-name matches (score 1000) are
// at the front; peel them into their own section and drop them from the
// fuzzy page list so they aren't shown twice.
var exact []searchResult
for len(pages) > 0 && pages[0].Score == exactNameScore {
exact = append(exact, pages[0])
pages = pages[1:]
}
title := "Search" title := "Search"
if query != "" { if query != "" {
title = "Search: " + query title = "Search: " + query
@@ -108,6 +124,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
data := searchPageData{ data := searchPageData{
Title: title, Title: title,
Query: query, Query: query,
Exact: exact,
Pages: capResults(pages), Pages: capResults(pages),
Files: capResults(files), Files: capResults(files),
PageTotal: len(pages), PageTotal: len(pages),
@@ -242,7 +259,7 @@ func fileURL(relPath string) string {
// large file volumes don't pay the edit-distance cost per query. // large file volumes don't pay the edit-distance cost per query.
func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int { func scoreName(nameLower string, nameTokens []string, qLower string, qTokens []string, fuzzy bool) int {
if nameLower == qLower { if nameLower == qLower {
return 1000 return exactNameScore
} }
score := 0 score := 0
for _, qt := range qTokens { for _, qt := range qTokens {