about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.golangci.yaml2
-rw-r--r--CHANGELOG.md40
-rw-r--r--README.md12
-rw-r--r--frontend/static/search.js49
-rw-r--r--frontend/static/style.css30
-rw-r--r--internal/components/combined.go4
-rw-r--r--internal/components/markdown.go2
-rw-r--r--internal/components/options.go6
-rw-r--r--internal/components/packages.go6
-rw-r--r--internal/components/page.go5
-rw-r--r--internal/components/search.go27
-rw-r--r--internal/index/indexer.go15
-rw-r--r--internal/index/search.go27
-rw-r--r--internal/index/search_test.go150
-rw-r--r--internal/server/mux.go22
-rw-r--r--modd.conf2
-rw-r--r--nix/modules/default.nix2
-rw-r--r--nix/package.nix2
18 files changed, 345 insertions, 58 deletions
diff --git a/.golangci.yaml b/.golangci.yaml
index e44d5d8..1e968df 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -21,6 +21,8 @@ linters:
         - unconvert
         - wrapcheck
 linters-settings:
+    paralleltest:
+        ignore-missing: true
     gosec:
         excludes:
             - G115
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87a6f0a..f6988be 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,47 @@
 # Changelog
 
-## [v0.1.30](https://git.alanpearce.eu/searchix/diff/?id=v0.1.29&id2=5762645aedc4d39a9e6caeb227410ca9bae2d2b5) (2025-03-18)
+## [v0.1.31](https://git.alanpearce.eu/searchix/diff/?id=v0.1.30&id2=d4ec6e5beecd549114dafd0b7c3b4a9d910388fb) (2025-03-21)
 
 ### Features
 
+- **nixos-module:** allow setting environment variables
+  ([e8fbdf3](https://git.alanpearce.eu/searchix/commit/?id=e8fbdf3bd12c8920a6e9bd84b34e787764b11eaf))
+- **nixos-module:** allow setting environment variables
+  ([78fc3e6](https://git.alanpearce.eu/searchix/commit/?id=78fc3e6cd43e6df90e17067fe0eb52b9badf9a75))
+- demote NUR results in combined search
+  ([782b636](https://git.alanpearce.eu/searchix/commit/?id=782b636c6ba9ebccecf46c7a1e5583e8108baf9a))
+- promote results with literal or prefix name matches
+  ([49a07fb](https://git.alanpearce.eu/searchix/commit/?id=49a07fb0b513dcaeb6241f4d87c160b9e0119260))
+- make list of source links dynamic
+  ([7247322](https://git.alanpearce.eu/searchix/commit/?id=7247322a386f065c643dc58f0ae5b57ad7ec1cc1))
+- enable NUR package import
+  ([2705e97](https://git.alanpearce.eu/searchix/commit/?id=2705e97ce1cf7d6a399c5f0175c36562fdef3352))
+- wrap search form in semantic <search> element
+  ([ff1e953](https://git.alanpearce.eu/searchix/commit/?id=ff1e9539fca1f011cfd52d0309a373f211c3fd10))
+- show last/next/current indexing run time
+  ([383ee78](https://git.alanpearce.eu/searchix/commit/?id=383ee780613116e78db9114a39a2d6127533463c))
+- shorten shutdown timeout in development
+  ([49e3004](https://git.alanpearce.eu/searchix/commit/?id=49e3004d33bf84aa081460e4a6d89a8d84cc12b0))
+- Convert templ components to gomponents
+  ([896d844](https://git.alanpearce.eu/searchix/commit/?id=896d844cac976afd0ee8aa73dd2fb28e15e7ac79))
+
+### Fixes
+
+- remove rendering from search timeout restriction
+  ([d4ec6e5](https://git.alanpearce.eu/searchix/commit/?id=d4ec6e5beecd549114dafd0b7c3b4a9d910388fb))
+- package programs displayed off-centre
+  ([de98780](https://git.alanpearce.eu/searchix/commit/?id=de987806cd030e85a22e11b35835a3524068adb7))
+- footer spacing
+  ([c0c02ac](https://git.alanpearce.eu/searchix/commit/?id=c0c02ac768a144f4417edfba967a4f7857a150b9))
+- detach version from rest of footer text
+  ([6b342b8](https://git.alanpearce.eu/searchix/commit/?id=6b342b83cedec82d240fc820d9696d3bb3eda8a2))
+- wrong pagination links for combined results
+  ([9102aef](https://git.alanpearce.eu/searchix/commit/?id=9102aef53c5fb73585359306a518e726a3623731))
+
+### [v0.1.30](https://git.alanpearce.eu/searchix/diff/?id=v0.1.29&id2=v0.1.30) (2025-03-18)
+
+#### Features
+
 - show version number and link in footer
   ([5762645](https://git.alanpearce.eu/searchix/commit/?id=5762645aedc4d39a9e6caeb227410ca9bae2d2b5))
 - split compound words in names into n-grams
diff --git a/README.md b/README.md
index 4430131..cb3877e 100644
--- a/README.md
+++ b/README.md
@@ -7,20 +7,22 @@ A search tool to find options and packages in the NixOS ecosystem. Aims to be li
 - NixOS options
 - Nix packages
 - [Nix darwin](https://github.com/LnL7/nix-darwin) options
-- [Home manager](https://github.com/nix-community/home-manager) options.
+- [Home manager](https://github.com/nix-community/home-manager) options
+- [Nix User Repository](https://github.com/nix-community/NUR) packages
 
 There is an instance running at [searchix.alanpearce.eu](https://searchix.alanpearce.eu/), which uses the following channels, with updates attempted daily:
 
 - nixos-options: nixos-unstable
 - nixpkgs: nixos-unstable
-- darwin: master
-- home-manager: master
+- darwin: master branch
+- home-manager: master branch
+- nur: main branch
 
-You can also [run it yourself](./docs/running.md), if you're feeling bold. It's very light-weight!
+You can also [run it yourself](./docs/running.md), if you're feeling bold. It's quite lightweight!
 
 ## Status
 
-**Alpha**
+**Beta**
 
 Expect breakage. Search results are not expected to match the quality of [search.nixos.org](https://search.nixos.org/), the priority is more on having multiple sources in one location.
 
diff --git a/frontend/static/search.js b/frontend/static/search.js
index e16c83c..0bf4a5e 100644
--- a/frontend/static/search.js
+++ b/frontend/static/search.js
@@ -28,14 +28,24 @@ if (window.trustedTypes && trustedTypes.createPolicy) {
   });
 }
 
+/**
+ *
+ * @param {MouseEvent} ev
+ */
 function openSiblingDialog(ev) {
-  const dialog = ev.target.nextElementSibling;
-  dialog.showModal();
-  dialog.querySelector("button").addEventListener("click", function () {
-    dialog.close();
-  });
+  if (!(ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey)) {
+    const dialog = ev.target.nextElementSibling;
+    dialog.showModal();
+    dialog.querySelector("button").addEventListener("click", function () {
+      dialog.close();
+    });
+  }
 }
 
+/**
+ *
+ * @param {HTMLElement} results
+ */
 function addOpenDialogListeners(results) {
   results.querySelectorAll("a.open-dialog").forEach(function (element) {
     element.addEventListener("click", handleDialogOpen);
@@ -45,12 +55,20 @@ function addOpenDialogListeners(results) {
   });
 }
 
+/**
+ *
+ * @param {MouseEvent} ev
+ */
 function paginationLinkClicked(ev) {
   const url = new URL(ev.target.href);
   getResults(url);
   ev.preventDefault();
 }
 
+/**
+ *
+ * @param {HTMLElement} pagination
+ */
 function addPaginationEventListeners(pagination) {
   Array.from(pagination.children).forEach((child) =>
     child.addEventListener("click", paginationLinkClicked),
@@ -70,6 +88,10 @@ function renderResults(html) {
   }
 }
 
+/**
+ *
+ * @param {URL} url
+ */
 async function getResults(url) {
   try {
     state.url = url.toJSON();
@@ -141,6 +163,10 @@ document.querySelector("a.current").addEventListener("click", function (ev) {
   queryInput.value = "";
 });
 
+/**
+ *
+ * @param {string} html
+ */
 function renderDetails(html) {
   const fragment = detailsRange.createContextualFragment(
     escapePolicy !== null ? escapePolicy.createHTML(html) : html,
@@ -157,6 +183,10 @@ dialog.querySelector("button").addEventListener("click", function () {
   dialog.close();
 });
 
+/**
+ *
+ * @param {URL} url
+ */
 async function getDetail(url) {
   try {
     state.url = url.toJSON();
@@ -178,9 +208,14 @@ async function getDetail(url) {
   }
 }
 
+/**
+ * @param {MouseEvent} ev
+ */
 function handleDialogOpen(ev) {
-  getDetail(new URL(ev.target.href));
-  ev.preventDefault();
+  if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) {
+    getDetail(new URL(ev.target.href));
+    ev.preventDefault();
+  }
 }
 
 if (state.opened.length > 0) {
diff --git a/frontend/static/style.css b/frontend/static/style.css
index 8766844..f7d1f75 100644
--- a/frontend/static/style.css
+++ b/frontend/static/style.css
@@ -2,7 +2,7 @@
   --sans-font: -apple-system, BlinkMacSystemFont, sans-serif;
   --standard-border-radius: 0;
   --preformatted: var(--code);
-  --min-width: 60rem;
+  --min-width: 80rem;
   --accent-error: #ffe0e0;
 }
 
@@ -162,13 +162,39 @@ dialog > h2 {
 }
 
 table {
-  width: 100%;
   margin-top: 0;
+  width: 100%;
+  table-layout: fixed;
+  white-space: nowrap;
+}
+
+th.description {
+  width: 50%;
+}
+
+th.score {
+  width: 8ex;
 }
 
 td,
 th {
   padding: 0.25rem 0.5rem;
+  text-overflow: ellipsis;
+  overflow: clip;
+}
+
+tr {
+  height: 1.5rem;
+}
+
+td.description > * {
+  max-width: 100%;
+  text-overflow: ellipsis;
+  overflow: clip;
+}
+
+td.description > *:not(:first-child) {
+  display: none;
 }
 
 ul:only-child {
diff --git a/internal/components/combined.go b/internal/components/combined.go
index fe97e14..8c2dc34 100644
--- a/internal/components/combined.go
+++ b/internal/components/combined.go
@@ -31,7 +31,7 @@ func Combined(result *index.Result) g.Node {
 				Th(Scope("col"), g.Text("Attribute")),
 				Th(Scope("col"), g.Text("Description")),
 				g.If(config.DevMode,
-					Th(Scope("col"), g.Text("Score")),
+					Th(Scope("col"), Class("score"), g.Text("Score")),
 				),
 			),
 		),
@@ -41,7 +41,7 @@ func Combined(result *index.Result) g.Node {
 					Td(
 						openCombinedDialogLink(nix.GetKey(hit.Data)),
 					),
-					Td(
+					Td(Class("description"),
 						CombinedData(hit.Data),
 					),
 					g.If(config.DevMode,
diff --git a/internal/components/markdown.go b/internal/components/markdown.go
index 405ab52..a26fe3d 100644
--- a/internal/components/markdown.go
+++ b/internal/components/markdown.go
@@ -4,7 +4,7 @@ import (
 	"regexp"
 )
 
-var firstSentenceRegexp = regexp.MustCompile(`^.*?\.[[:space:]]`)
+var firstSentenceRegexp = regexp.MustCompile(`^.+?(\.[[:space:]]|:\n)`)
 
 func firstSentence[T ~string](text T) T {
 	if fs := firstSentenceRegexp.FindString(string(text)); fs != "" {
diff --git a/internal/components/options.go b/internal/components/options.go
index 1d01784..630fecd 100644
--- a/internal/components/options.go
+++ b/internal/components/options.go
@@ -14,9 +14,9 @@ func Options(result *index.Result) g.Node {
 		THead(
 			Tr(
 				Th(Scope("col"), g.Text("Title")),
-				Th(Scope("col"), g.Text("Description")),
+				Th(Scope("col"), Class("description"), g.Text("Description")),
 				g.If(config.DevMode,
-					Th(Scope("col"), g.Text("Score")),
+					Th(Scope("col"), Class("score"), g.Text("Score")),
 				),
 			),
 		),
@@ -37,7 +37,7 @@ func optionRow(hit index.DocumentMatch, o nix.Option) g.Node {
 		Td(
 			openDialogLink(o.Name),
 		),
-		Td(
+		Td(Class("description"),
 			firstSentence(o.Description),
 			Dialog(ID(o.Name)),
 		),
diff --git a/internal/components/packages.go b/internal/components/packages.go
index db45302..90bf92d 100644
--- a/internal/components/packages.go
+++ b/internal/components/packages.go
@@ -15,9 +15,9 @@ func Packages(result *index.Result) g.Node {
 			Tr(
 				Th(Scope("col"), g.Text("Attribute")),
 				Th(Scope("col"), g.Text("Name")),
-				Th(Scope("col"), g.Text("Description")),
+				Th(Scope("col"), Class("description"), g.Text("Description")),
 				g.If(config.DevMode,
-					Th(Scope("col"), g.Text("Score")),
+					Th(Scope("col"), Class("score"), g.Text("Score")),
 				),
 			),
 		),
@@ -41,7 +41,7 @@ func packageRow(hit index.DocumentMatch, p nix.Package) g.Node {
 		Td(
 			g.Text(p.Name),
 		),
-		Td(
+		Td(Class("description"),
 			g.Text(p.Description),
 		),
 		g.If(config.DevMode,
diff --git a/internal/components/page.go b/internal/components/page.go
index 6830247..06e16ae 100644
--- a/internal/components/page.go
+++ b/internal/components/page.go
@@ -90,7 +90,10 @@ func Page(tdata TemplateData, children ...g.Node) g.Node {
 						g.Group([]g.Node{
 							g.Text("Searchix "),
 							A(
-								Href("https://git.sr.ht/~alanpearce/searchix/refs/"+config.Version),
+								Href(
+									"https://git.sr.ht/~alanpearce/searchix/tree/"+config.Version+"/CHANGELOG.md",
+								),
+								TitleAttr("View changelog"),
 								g.Text(config.Version),
 							),
 							g.Text(" "),
diff --git a/internal/components/search.go b/internal/components/search.go
index b4ef7bd..3db1cd4 100644
--- a/internal/components/search.go
+++ b/internal/components/search.go
@@ -19,6 +19,7 @@ func SearchForm(tdata TemplateData, r ResultData) g.Node {
 			Input(
 				ID("query"),
 				Aria("labelledby", "legend"),
+				MinLength("2"),
 				Name("query"),
 				Type("search"),
 				Value(r.Query),
@@ -45,31 +46,33 @@ func SearchPage(tdata TemplateData, r ResultData, children ...g.Node) g.Node {
 				g.Text("Indexing in progress, started "),
 				Time(
 					DateTime(Indexing.StartedAt.Format(time.RFC3339)),
-					Title(Indexing.StartedAt.Format(time.RFC3339)),
+					Title(Indexing.StartedAt.Format(time.DateTime)),
 					g.Text(time.Since(Indexing.StartedAt).Round(time.Second).String()),
 				),
 				g.Text(" ago. "),
 				g.If(!Indexing.FinishedAt.IsZero(),
-					g.Text("Last run took "),
-					Time(
-						DateTime(Indexing.FinishedAt.Format(time.RFC3339)),
-						Title(Indexing.FinishedAt.Format(time.RFC3339)),
-						g.Text(time.Since(Indexing.FinishedAt).Round(time.Minute).String()),
-					),
+					g.Group([]g.Node{
+						g.Text("Last run took "),
+						Time(
+							DateTime(Indexing.FinishedAt.Format(time.RFC3339)),
+							Title(Indexing.FinishedAt.Format(time.DateTime)),
+							g.Text(time.Since(Indexing.FinishedAt).Round(time.Minute).String()),
+						),
+					}),
 				),
 			),
 			P(
 				g.Text("Indexing last ran "),
 				Time(
 					DateTime(Indexing.FinishedAt.Format(time.RFC3339)),
-					Title(Indexing.FinishedAt.Format(time.RFC3339)),
-					g.Text(time.Since(Indexing.FinishedAt).Round(time.Minute).String()),
+					Title(Indexing.FinishedAt.Format(time.DateTime)),
+					g.Textf("%.0f hours ago", time.Since(Indexing.FinishedAt).Hours()),
 				),
-				g.Text(" ago, will run again in "),
+				g.Text(", will run again in "),
 				Time(
 					DateTime(Indexing.NextRun.Format(time.RFC3339)),
-					Title(Indexing.NextRun.Format(time.RFC3339)),
-					g.Text(time.Until(Indexing.NextRun).Round(time.Minute).String()),
+					Title(Indexing.NextRun.Format(time.DateTime)),
+					g.Textf("%.0f hours", time.Until(Indexing.NextRun).Hours()),
 				),
 				g.Text("."),
 			),
diff --git a/internal/index/indexer.go b/internal/index/indexer.go
index 8cbc8e2..c4032e8 100644
--- a/internal/index/indexer.go
+++ b/internal/index/indexer.go
@@ -8,6 +8,7 @@ import (
 	"math"
 	"os"
 	"path"
+	"path/filepath"
 	"slices"
 
 	"go.alanpearce.eu/searchix/internal/file"
@@ -93,13 +94,6 @@ func createIndexMapping() (mapping.IndexMapping, errors.E) {
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not add custom analyser")
 	}
-	err = indexMapping.AddCustomAnalyzer("keyword_single", map[string]any{
-		"type":      keyword.Name,
-		"tokenizer": letter.Name,
-	})
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not add custom analyser")
-	}
 
 	identityFieldMapping := bleve.NewKeywordFieldMapping()
 
@@ -222,6 +216,13 @@ func OpenOrCreate(
 ) (*ReadIndex, *WriteIndex, bool, errors.E) {
 	var err errors.E
 	bleve.SetLog(zap.NewStdLog(options.Logger.Named("bleve").GetLogger()))
+	if !filepath.IsAbs(dataRoot) {
+		wd, err := os.Getwd()
+		if err != nil {
+			return nil, nil, false, errors.WithMessagef(err, "could not get working directory")
+		}
+		dataRoot = filepath.Join(wd, dataRoot)
+	}
 
 	indexPath := path.Join(dataRoot, indexBaseName)
 	metaPath := path.Join(dataRoot, metaBaseName)
diff --git a/internal/index/search.go b/internal/index/search.go
index 292661e..a8124c7 100644
--- a/internal/index/search.go
+++ b/internal/index/search.go
@@ -128,8 +128,8 @@ func (index *ReadIndex) Search(
 
 	// match the user's query in any field ...
 	query.AddMust(bleve.NewDisjunctionQuery(
-		bleve.NewTermQuery(keyword),
-		bleve.NewPrefixQuery(keyword),
+		setBoost(bleve.NewTermQuery(keyword), 50),
+		setBoost(bleve.NewPrefixQuery(keyword), 25),
 		bleve.NewMatchPhraseQuery(keyword),
 		bleve.NewMatchQuery(keyword),
 	))
@@ -140,13 +140,23 @@ func (index *ReadIndex) Search(
 		)
 	} else {
 		q := bleve.NewDisjunctionQuery(
-			setBoost(setField(bleve.NewTermQuery("nixpkgs"), "Source"), -150),
-			setBoost(setField(bleve.NewTermQuery("nur"), "Source"), -200),
+			setBoost(setField(bleve.NewTermQuery("nixpkgs"), "Source"), -1000),
+			setBoost(setField(bleve.NewTermQuery("nur"), "Source"), -5000),
 		)
 
 		query.AddShould(q)
 	}
 
+	mainProgramQuery := bleve.NewMatchQuery(keyword)
+	mainProgramQuery.SetField("MainProgram")
+	mainProgramQuery.SetBoost(50)
+	query.AddShould(mainProgramQuery)
+
+	mainProgramLiteralQuery := bleve.NewTermQuery(keyword)
+	mainProgramLiteralQuery.SetField("MainProgram")
+	mainProgramLiteralQuery.SetBoost(100)
+	query.AddShould(mainProgramLiteralQuery)
+
 	programsQuery := bleve.NewMatchQuery(keyword)
 	programsQuery.SetField("Programs")
 	programsQuery.SetBoost(2)
@@ -215,3 +225,12 @@ func (index *ReadIndex) GetDocument(
 
 	return nil, err
 }
+
+func (index *ReadIndex) Close() error {
+	err := index.index.Close()
+	if err != nil {
+		return errors.WithStack(err)
+	}
+
+	return nil
+}
diff --git a/internal/index/search_test.go b/internal/index/search_test.go
new file mode 100644
index 0000000..339a0de
--- /dev/null
+++ b/internal/index/search_test.go
@@ -0,0 +1,150 @@
+package index_test
+
+import (
+	"context"
+	"maps"
+	"math"
+	"slices"
+	"testing"
+	"time"
+
+	"go.alanpearce.eu/searchix/internal/config"
+	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/searchix/internal/nix"
+	"go.alanpearce.eu/x/log"
+)
+
+const dataRoot = "../../data"
+
+func TestSearchGitPackagesFirst(t *testing.T) {
+	log := log.Configure(false)
+	cfg := config.DefaultConfig
+
+	read, _, exists, err := index.OpenOrCreate(dataRoot, false, &index.Options{
+		Logger:    log.Named("index"),
+		LowMemory: false,
+	})
+	defer read.Close()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !exists {
+		t.Fatal("expected index to exist")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+	defer cancel()
+
+	source := cfg.Importer.Sources["nixpkgs"]
+	if source == nil || !source.Enable {
+		t.Fatal("expected source to exist and be enabled")
+	}
+
+	result, err := read.Search(
+		ctx,
+		source,
+		"git",
+		0,
+		100,
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if result.Total < 4 {
+		t.Errorf("Expected at least 4 results, got %d", result.Total)
+	}
+	important := map[string]int{
+		"git":        0,
+		"git-doc":    0,
+		"gitFull":    0,
+		"gitMinimal": 0,
+		"gitSVN":     0,
+	}
+	var i int
+	for hit := range result.Hits {
+		data := hit.Data.(nix.Package)
+		if _, found := important[data.Attribute]; found {
+			important[data.Attribute] = i
+		}
+		i++
+	}
+	if slices.Max(slices.Collect(maps.Values(important))) > len(important) {
+		t.Errorf(
+			"Expected all of %s to be the first %d matches, got %v",
+			slices.Collect(maps.Keys(important)),
+			len(important),
+			important,
+		)
+	}
+}
+
+func TestSearchJujutsuPackagesFirst(t *testing.T) {
+	log := log.Configure(false)
+	cfg := config.DefaultConfig
+
+	read, _, exists, err := index.OpenOrCreate(dataRoot, false, &index.Options{
+		Logger:    log.Named("index"),
+		LowMemory: false,
+	})
+	defer read.Close()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !exists {
+		t.Fatal("expected index to exist")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+	defer cancel()
+
+	source := cfg.Importer.Sources["nixpkgs"]
+	if source == nil || !source.Enable {
+		t.Fatal("expected source to exist and be enabled")
+	}
+
+	result, err := read.Search(
+		ctx,
+		source,
+		"jj",
+		0,
+		100,
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if result.Total < 4 {
+		t.Errorf("Expected at least 4 results, got %d", result.Total)
+	}
+	important := map[string]int{
+		"jj":      0,
+		"jujutsu": 0,
+		"lazyjj":  0,
+		"jjui":    0,
+		"jj-fzf":  0,
+	}
+	matches := []string{}
+	unwanted := "javacc"
+	unwantedIndex := math.MaxInt
+	var i int
+	for hit := range result.Hits {
+		data := hit.Data.(nix.Package)
+		if _, found := important[data.Attribute]; found {
+			matches = append(matches, data.Attribute)
+		} else if data.Attribute == unwanted {
+			unwantedIndex = i
+			matches = append(matches, data.Attribute)
+		}
+		i++
+	}
+	if slices.Max(slices.Collect(maps.Values(important))) > unwantedIndex {
+		t.Errorf(
+			"Expected all of %s to be above unwanted result %s at index %d. Results: %v",
+			slices.Collect(maps.Keys(important)),
+			unwanted,
+			unwantedIndex,
+			matches,
+		)
+	}
+}
diff --git a/internal/server/mux.go b/internal/server/mux.go
index f7a82d8..968b37c 100644
--- a/internal/server/mux.go
+++ b/internal/server/mux.go
@@ -93,12 +93,15 @@ func NewMux(
 				}
 			}
 
-			ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
-			defer cancel()
-
 			if r.URL.Query().Has("query") {
 				qs := r.URL.Query().Get("query")
 
+				if len(qs) < 2 {
+					errorHandler(w, r, "Query too short", http.StatusBadRequest)
+
+					return
+				}
+
 				var pageSize int = search.DefaultPageSize
 				var pageNumber = 1
 				if pg := r.URL.Query().Get("page"); pg != "" {
@@ -110,11 +113,15 @@ func NewMux(
 					}
 					if pageNumber == 0 {
 						pageNumber = 1
-						pageSize = math.MaxInt
+						pageSize = config.MaxResultsShowAll
 					}
 				}
 				page := pagination.New(pageNumber, pageSize)
+
+				ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
 				results, err := index.Search(ctx, source, qs, page.From, page.Size)
+				cancel()
+
 				if err != nil {
 					if err == context.DeadlineExceeded {
 						errorHandler(w, r, "Search timed out", http.StatusInternalServerError)
@@ -126,7 +133,8 @@ func NewMux(
 
 					return
 				}
-				if pageSize == math.MaxInt && results.Total > config.MaxResultsShowAll {
+				if pageSize == config.MaxResultsShowAll &&
+					results.Total > config.MaxResultsShowAll {
 					errorHandler(w, r, "Too many results, use pagination", http.StatusBadRequest)
 				}
 
@@ -219,9 +227,9 @@ func NewMux(
 			importerSingular := importerType.Singular()
 
 			ctx, cancel := context.WithTimeout(r.Context(), searchTimeout)
-			defer cancel()
-
 			doc, err := index.GetDocument(ctx, source, r.PathValue("id"))
+			cancel()
+
 			if err != nil {
 				errorHandler(
 					w,
diff --git a/modd.conf b/modd.conf
index 0a3e6a1..c4874f5 100644
--- a/modd.conf
+++ b/modd.conf
@@ -3,5 +3,5 @@ internal/index/indexer.go {
 }
 
 **/*.go config.toml {
-  daemon +sigint: go run ./cmd/searchix-web --dev --config config.toml
+  daemon +sigint: go run -ldflags="-X go.alanpearce.eu/searchix/internal/config.Version=$(git describe --tags --abbrev=0)" ./cmd/searchix-web --dev --config config.toml
 }
diff --git a/nix/modules/default.nix b/nix/modules/default.nix
index ba425ca..5184dde 100644
--- a/nix/modules/default.nix
+++ b/nix/modules/default.nix
@@ -200,7 +200,7 @@ in
       description = ''
         Configuration for searchix.
 
-        See https://git.alanpearce.eu/searchix/tree/defaults.toml?h=v0.1.30
+        See https://git.alanpearce.eu/searchix/tree/defaults.toml?h=v0.1.31
       '';
     };
   };
diff --git a/nix/package.nix b/nix/package.nix
index cf00514..996c0c4 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -13,7 +13,7 @@
 , css
 }:
 let
-  version = "0.1.30";
+  version = "0.1.31";
 in
 buildGoApplication {
   pname = "searchix";