about summary refs log tree commit diff stats
diff options
context:
space:
mode:
-rw-r--r--.editorconfig2
-rw-r--r--.gitignore3
-rw-r--r--defaults.toml8
-rw-r--r--frontend/assets.go12
-rw-r--r--frontend/static/search.js4
-rw-r--r--frontend/static/style.css11
-rw-r--r--frontend/templates/blocks/error.gotmpl6
-rw-r--r--frontend/templates/blocks/option.gotmpl48
-rw-r--r--frontend/templates/blocks/options.gotmpl25
-rw-r--r--frontend/templates/blocks/package.gotmpl69
-rw-r--r--frontend/templates/blocks/packages.gotmpl30
-rw-r--r--frontend/templates/blocks/results.gotmpl21
-rw-r--r--frontend/templates/blocks/search.gotmpl37
-rw-r--r--frontend/templates/index.gotmpl55
-rw-r--r--go.mod2
-rw-r--r--go.sum2
-rw-r--r--gomod2nix.toml6
-rw-r--r--internal/components/data.go37
-rw-r--r--internal/components/detail.templ20
-rw-r--r--internal/components/error.templ18
-rw-r--r--internal/components/homepage.templ10
-rw-r--r--internal/components/markdown.templ35
-rw-r--r--internal/components/optionDetail.templ58
-rw-r--r--internal/components/options.templ34
-rw-r--r--internal/components/packageDetail.templ99
-rw-r--r--internal/components/packages.templ37
-rw-r--r--internal/components/page.templ91
-rw-r--r--internal/components/results.templ44
-rw-r--r--internal/components/search.templ34
-rw-r--r--internal/config/default.go4
-rw-r--r--internal/config/structs.go4
-rw-r--r--internal/server/error.go10
-rw-r--r--internal/server/mux.go93
-rw-r--r--internal/server/templates.go25
-rw-r--r--modd.conf5
-rw-r--r--nix/dev-shell.nix1
-rw-r--r--nix/package.nix1
37 files changed, 611 insertions, 390 deletions
diff --git a/.editorconfig b/.editorconfig
index 34e76d0..d0afaf3 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -8,7 +8,7 @@ indent_style = space
 insert_final_newline = true
 trim_trailing_whitespace = true
 
-[{justfile,go.mod,go.sum,*.go,.gitmodules}]
+[{justfile,go.mod,go.sum,*.go,*.templ,.gitmodules}]
 indent_style = tab
 
 [*.yaml]
diff --git a/.gitignore b/.gitignore
index 4953924..f92bc49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,6 @@ go.work
 /frontend/static/base.css
 /data/
 /config.toml
+
+*_templ.go
+*_templ.txt
diff --git a/defaults.toml b/defaults.toml
index 66eb69d..f35b539 100644
--- a/defaults.toml
+++ b/defaults.toml
@@ -71,6 +71,8 @@ UpdateAt = '04:00:00'
 [Importer.Sources.darwin]
 # Human-readable name of source for generating links
 Name = 'Darwin'
+# Order in which to show source in web interface.
+Order = 1
 # Machine-readable name of source. Must be URL- and path-safe.
 Key = 'darwin'
 # Controls whether to show in the web interface and to run fetch/import jobs.
@@ -102,6 +104,8 @@ Repo = 'nix-darwin'
 [Importer.Sources.home-manager]
 # Human-readable name of source for generating links
 Name = 'Home Manager'
+# Order in which to show source in web interface.
+Order = 2
 # Machine-readable name of source. Must be URL- and path-safe.
 Key = 'home-manager'
 # Controls whether to show in the web interface and to run fetch/import jobs.
@@ -133,6 +137,8 @@ Repo = 'home-manager'
 [Importer.Sources.nixos]
 # Human-readable name of source for generating links
 Name = 'NixOS'
+# Order in which to show source in web interface.
+Order = 0
 # Machine-readable name of source. Must be URL- and path-safe.
 Key = 'nixos'
 # Controls whether to show in the web interface and to run fetch/import jobs.
@@ -164,6 +170,8 @@ Repo = 'nixpkgs'
 [Importer.Sources.nixpkgs]
 # Human-readable name of source for generating links
 Name = 'Nix Packages'
+# Order in which to show source in web interface.
+Order = 3
 # Machine-readable name of source. Must be URL- and path-safe.
 Key = 'nixpkgs'
 # Controls whether to show in the web interface and to run fetch/import jobs.
diff --git a/frontend/assets.go b/frontend/assets.go
index 46369fa..7a90d80 100644
--- a/frontend/assets.go
+++ b/frontend/assets.go
@@ -12,8 +12,8 @@ import (
 )
 
 var Assets = &AssetCollection{
-	Scripts:     make(map[string]*Asset),
-	Stylesheets: make(map[string]*Asset),
+	Scripts:     []*Asset{},
+	Stylesheets: []*Asset{},
 	ByPath:      make(map[string]*Asset),
 }
 
@@ -25,8 +25,8 @@ type Asset struct {
 }
 
 type AssetCollection struct {
-	Scripts     map[string]*Asset
-	Stylesheets map[string]*Asset
+	Scripts     []*Asset
+	Stylesheets []*Asset
 	ByPath      map[string]*Asset
 }
 
@@ -61,7 +61,7 @@ func hashScripts() error {
 		if err != nil {
 			return err
 		}
-		Assets.Scripts[filename] = asset
+		Assets.Scripts = append(Assets.Scripts, asset)
 		Assets.ByPath[asset.URL] = asset
 	}
 
@@ -78,7 +78,7 @@ func hashStyles() error {
 		if err != nil {
 			return err
 		}
-		Assets.Stylesheets[filename] = asset
+		Assets.Stylesheets = append(Assets.Stylesheets, asset)
 		Assets.ByPath[asset.URL] = asset
 	}
 
diff --git a/frontend/static/search.js b/frontend/static/search.js
index e3777d7..df11d09 100644
--- a/frontend/static/search.js
+++ b/frontend/static/search.js
@@ -6,8 +6,8 @@ const results = document.getElementById("results");
 let pagination = document.getElementById("pagination");
 
 const resultsRange = new Range();
-resultsRange.setStartBefore(results.firstChild);
-resultsRange.setEndAfter(results.lastChild);
+resultsRange.setStart(results, 0);
+resultsRange.setEnd(results, 0);
 
 const detailsRange = new Range();
 detailsRange.setStartAfter(dialog.firstElementChild);
diff --git a/frontend/static/style.css b/frontend/static/style.css
index a202b89..7cdc6c8 100644
--- a/frontend/static/style.css
+++ b/frontend/static/style.css
@@ -169,3 +169,14 @@ td,
 th {
   padding: 0.25rem 0.5rem;
 }
+
+ul:only-child {
+  padding-inline-start: unset;
+  margin: unset;
+}
+
+li {
+  display: inline-block;
+  margin-right: 1ex;
+  list-style: none;
+}
diff --git a/frontend/templates/blocks/error.gotmpl b/frontend/templates/blocks/error.gotmpl
deleted file mode 100644
index 1352b2e..0000000
--- a/frontend/templates/blocks/error.gotmpl
+++ /dev/null
@@ -1,6 +0,0 @@
-{{- define "main" }}
-  <p class="notice error">
-    {{ .Code }}
-    {{ .Message }}
-  </p>
-{{- end }}
diff --git a/frontend/templates/blocks/option.gotmpl b/frontend/templates/blocks/option.gotmpl
deleted file mode 100644
index 2248708..0000000
--- a/frontend/templates/blocks/option.gotmpl
+++ /dev/null
@@ -1,48 +0,0 @@
-{{- define "main" }}
-  {{- with .Document }}
-    <h2>{{ .Name }}</h2>
-    {{ markdown .Description }}
-    <dl>
-      {{- with .Type }}
-        <dt>Type</dt>
-        <dd><code>{{ . }}</code></dd>
-      {{- end }}
-      {{- with .Default }}
-        {{- if or .Text .Markdown }}
-          <dt>Default</dt>
-          <dd>
-            {{- if .Markdown }}
-              {{ markdown .Markdown }}
-            {{- else }}
-              <pre><code>{{ .Text }}</code></pre>
-            {{- end }}
-          </dd>
-        {{- end }}
-      {{- end }}
-      {{- with .Example }}
-        {{- if or .Text .Markdown }}
-          <dt>Example</dt>
-          <dd>
-            {{- if .Markdown }}
-              {{ markdown .Markdown }}
-            {{- else }}
-              <pre><code>{{ .Text }}</code></pre>
-            {{- end }}
-          </dd>
-        {{- end }}
-      {{- end }}
-      {{- with .RelatedPackages }}
-        <dt>Related Packages</dt>
-        <dd>{{ . }}</dd>
-      {{- end }}
-      {{- with .Declarations }}
-        <dt>Declared</dt>
-        {{- range . }}
-          <dd>
-            <a href="{{ .URL }}">{{ .Name }}</a>
-          </dd>
-        {{- end }}
-      {{- end }}
-    </dl>
-  {{- end }}
-{{- end }}
diff --git a/frontend/templates/blocks/options.gotmpl b/frontend/templates/blocks/options.gotmpl
deleted file mode 100644
index 5a08bae..0000000
--- a/frontend/templates/blocks/options.gotmpl
+++ /dev/null
@@ -1,25 +0,0 @@
-{{- define "hits" }}
-  <table>
-    <thead>
-      <tr>
-        <th scope="col">Title</th>
-        <th scope="col">Description</th>
-      </tr>
-    </thead>
-    <tbody>
-      {{- range . }}
-        {{- with .Data }}
-          <tr>
-            <td>
-              <a href="{{ .Name }}" class="open-dialog">{{ .Name }}</a>
-            </td>
-            <td>
-              {{ markdown (firstSentence .Description) }}
-              <dialog id="{{ .Name }}"></dialog>
-            </td>
-          </tr>
-        {{- end }}
-      {{- end }}
-    </tbody>
-  </table>
-{{- end }}
diff --git a/frontend/templates/blocks/package.gotmpl b/frontend/templates/blocks/package.gotmpl
deleted file mode 100644
index a42a8b1..0000000
--- a/frontend/templates/blocks/package.gotmpl
+++ /dev/null
@@ -1,69 +0,0 @@
-{{- define "main" }}
-  {{- with .Document }}
-    <h2>
-      {{- if .Broken }}
-        <del>{{ .Attribute }}</del>
-      {{- else }}
-        {{ .Attribute }}
-      {{- end }}
-    </h2>
-    {{- if .LongDescription }}
-      {{ markdown .LongDescription }}
-    {{- else }}
-      <p>{{ .Description }}</p>
-    {{- end }}
-    <dl>
-      {{- with .MainProgram }}
-        <dt>Main Program</dt>
-        <dd>
-          <code>{{ . }}</code>
-        </dd>
-      {{- end }}
-      {{- with .Homepages }}
-        <dt>Homepage</dt>
-        <dd>
-          {{- range . }}
-            <a href="{{ . }}">{{ . }}</a>
-          {{- end }}
-        </dd>
-      {{- end }}
-      {{- with .Version }}
-        <dt>Version</dt>
-        <dd>{{ . }}</dd>
-      {{- end }}
-      {{- with .Licenses }}
-        <dt>License</dt>
-        <dd>
-          {{- range . }}
-            {{- if .URL }}
-              <a href="{{ .URL }}">{{ or .FullName .Name }}</a>
-            {{- else }}
-              {{ or .FullName .Name }}
-            {{- end }}
-            {{- with .AppendixURL }}
-              <a href="{{ . }}">Appendix</a>
-            {{- end }}
-          {{- end }}
-        </dd>
-      {{- end }}
-      {{- with .Maintainers }}
-        <dt>Maintainer{{ if gt (len .) 1 }}s{{ end }}</dt>
-        <dd>
-          {{- range . }}
-            {{- if .Github }}
-              <a href="https://github.com/{{ .Github }}">{{ .Name }}</a>
-            {{- else }}
-              {{ .Name }}
-            {{- end }}
-          {{- end }}
-        </dd>
-      {{- end }}
-      {{- with .Definition }}
-        <dt>Defined</dt>
-        <dd>
-          <a href="{{ . }}">Source</a>
-        </dd>
-      {{- end }}
-    </dl>
-  {{- end }}
-{{- end }}
diff --git a/frontend/templates/blocks/packages.gotmpl b/frontend/templates/blocks/packages.gotmpl
deleted file mode 100644
index cce97a0..0000000
--- a/frontend/templates/blocks/packages.gotmpl
+++ /dev/null
@@ -1,30 +0,0 @@
-{{- define "hits" }}
-  <table>
-    <thead>
-      <tr>
-        <th scope="col">Attribute</th>
-        <th scope="col">Name</th>
-        <th scope="col">Description</th>
-      </tr>
-    </thead>
-    <tbody>
-      {{- range . }}
-        {{- with .Data }}
-          <tr>
-            <td>
-              {{- with .Attribute }}
-                <a href="{{ . }}" class="open-dialog">{{ . }}</a>
-              {{- end }}
-            </td>
-            <td>
-              {{ .Name }}
-            </td>
-            <td>
-              {{ .Description }}
-            </td>
-          </tr>
-        {{- end }}
-      {{- end }}
-    </tbody>
-  </table>
-{{- end }}
diff --git a/frontend/templates/blocks/results.gotmpl b/frontend/templates/blocks/results.gotmpl
deleted file mode 100644
index ef6e1f1..0000000
--- a/frontend/templates/blocks/results.gotmpl
+++ /dev/null
@@ -1,21 +0,0 @@
-{{- define "results" }}
-  {{- with .Results }}
-    {{- if gt .Total 0 }}
-      {{ block "hits" .Hits }}
-      {{ end }}
-      <footer aria-label="pagination">
-        <nav id="pagination">
-          {{- with $.Prev }}
-            <a class="button" href="{{ . }}" rel="prev">Prev</a>
-          {{- end }}
-          {{- with $.Next }}
-            <a class="button" href="{{ . }}" rel="next">Next</a>
-          {{- end }}
-        </nav>
-        <span role="status">{{ .Total }} results</span>
-      </footer>
-    {{- else }}
-      <span role="status">Nothing found</span>
-    {{- end }}
-  {{- end }}
-{{- end }}
diff --git a/frontend/templates/blocks/search.gotmpl b/frontend/templates/blocks/search.gotmpl
deleted file mode 100644
index 93ae545..0000000
--- a/frontend/templates/blocks/search.gotmpl
+++ /dev/null
@@ -1,37 +0,0 @@
-{{- define "main" }}
-  <form id="search" role="search">
-    <fieldset>
-      <legend id="legend">
-        <h2>{{ sourceNameAndType .Source }} search</h2>
-      </legend>
-      <input
-        id="query"
-        aria-labelledby="legend"
-        name="query"
-        type="search"
-        value="{{ .Query }}"
-        autofocus
-        spellcheck="false"
-      />
-      <button>Search</button>
-    </fieldset>
-  </form>
-  <section id="results" role="list" aria-label="search results">
-    {{- if .Results }}
-      {{ block "results" . }}{{ end }}
-    {{- end }}
-  </section>
-  <dialog id="dialog">
-    <button autofocus>Close</button>
-  </dialog>
-{{- end }}
-
-{{- define "head" }}
-  {{- with (index .Assets.Scripts "static/search.js") }}
-    <script
-      src="{{ .URL }}"
-      defer
-      integrity="sha256-{{ .Base64SHA256 }}"
-    ></script>
-  {{- end }}
-{{- end }}
diff --git a/frontend/templates/index.gotmpl b/frontend/templates/index.gotmpl
deleted file mode 100644
index 7732dc8..0000000
--- a/frontend/templates/index.gotmpl
+++ /dev/null
@@ -1,55 +0,0 @@
-<!doctype html>
-<html lang="en-GB">
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <title>Searchix</title>
-    {{- range .Assets.Stylesheets }}
-      <link
-        href="{{ .URL }}"
-        rel="stylesheet"
-        integrity="sha256-{{ .Base64SHA256 }}"
-      />
-    {{- end }}
-    {{ block "head" . }}
-    {{ end }}
-    {{ .ExtraHeadHTML }}
-    {{- range $key, $value := .Sources }}
-      <link
-        rel="search"
-        type="application/opensearchdescription+xml"
-        title="Searchix {{ sourceNameAndType $value }}"
-        href="/{{ .Importer }}/{{ $key }}/opensearch.xml"
-      />
-    {{- end }}
-  </head>
-  <body>
-    <header>
-      <nav>
-        <h1><a href="/">Searchix</a></h1>
-        {{- range $key, $value := .Sources }}
-          <a
-            {{ if eq $.Source.Name $value.Name }}class="current"{{ end }}
-            href="/{{ .Importer }}/{{ $key }}/search{{ if and (ne $.Source.Name $value.Name) $.Query }}
-              {{- printf "?query=%s" $.Query -}}
-            {{ end }}"
-          >
-            {{- $value.Name -}}
-          </a>
-        {{- end }}
-      </nav>
-    </header>
-    <main>
-      {{ block "main" . }}
-        <p>
-          Search Nix Packages and options from NixOS, Darwin and Home-Manager
-        </p>
-        <a href="https://git.sr.ht/~alanpearce/searchix">Source code</a>
-      {{ end }}
-    </main>
-    <footer>
-      Made by <a href="https://alanpearce.eu">Alan Pearce</a>.
-      <a href="https://todo.sr.ht/~alanpearce/searchix">Report issues</a>
-    </footer>
-  </body>
-</html>
diff --git a/go.mod b/go.mod
index c049e9e..d7d7807 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.22.2
 
 require (
 	badc0de.net/pkg/flagutil v1.0.1
+	github.com/a-h/templ v0.2.707
 	github.com/andybalholm/brotli v1.1.0
 	github.com/bcicen/jstream v1.0.1
 	github.com/blevesearch/bleve/v2 v2.4.0
@@ -43,7 +44,6 @@ require (
 	github.com/golang/geo v0.0.0-20230421003525-6adc56603217 // indirect
 	github.com/golang/protobuf v1.5.4 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
-	github.com/google/go-cmp v0.6.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
diff --git a/go.sum b/go.sum
index 110e7fd..8cdbe2e 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ badc0de.net/pkg/flagutil v1.0.1 h1:0ZgBzd3FehDUA8DJ70/phsnDH61/3aYMyx8Wd84KqQo=
 badc0de.net/pkg/flagutil v1.0.1/go.mod h1:HwwkfbImu+u288bnLaYDGqBxkJzvqi5YzKofmgkMLvk=
 github.com/RoaringBitmap/roaring v1.9.4 h1:yhEIoH4YezLYT04s1nHehNO64EKFTop/wBhxv2QzDdQ=
 github.com/RoaringBitmap/roaring v1.9.4/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
+github.com/a-h/templ v0.2.707 h1:T1Gkd2ugbRglZ9rYw/VBchWOSZVKmetDbBkm4YubM7U=
+github.com/a-h/templ v0.2.707/go.mod h1:5cqsugkq9IerRNucNsI4DEamdHPsoGMQy99DzydLhM8=
 github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
 github.com/bcicen/jstream v1.0.1 h1:BXY7Cu4rdmc0rhyTVyT3UkxAiX3bnLpKLas9btbH5ck=
diff --git a/gomod2nix.toml b/gomod2nix.toml
index f433811..e9650a5 100644
--- a/gomod2nix.toml
+++ b/gomod2nix.toml
@@ -7,6 +7,9 @@ schema = 3
   [mod."github.com/RoaringBitmap/roaring"]
     version = "v1.9.4"
     hash = "sha256-OKOLQ/PsH6630Vb5/9yG28TLIPGxdG9WDbAZxgK8EcI="
+  [mod."github.com/a-h/templ"]
+    version = "v0.2.707"
+    hash = "sha256-UoM2qj8E7C4NBAMhS/2jrOw0Dj/gnsyZRL4NpRCWaMo="
   [mod."github.com/andybalholm/brotli"]
     version = "v1.1.0"
     hash = "sha256-njLViV4v++ZdgOWGWzlvkefuFvA/nkugl3Ta/h1nu/0="
@@ -88,9 +91,6 @@ schema = 3
   [mod."github.com/golang/snappy"]
     version = "v0.0.4"
     hash = "sha256-Umx+5xHAQCN/Gi4HbtMhnDCSPFAXSsjVbXd8n5LhjAA="
-  [mod."github.com/google/go-cmp"]
-    version = "v0.6.0"
-    hash = "sha256-qgra5jze4iPGP0JSTVeY5qV5AvEnEu39LYAuUCIkMtg="
   [mod."github.com/json-iterator/go"]
     version = "v1.1.12"
     hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM="
diff --git a/internal/components/data.go b/internal/components/data.go
new file mode 100644
index 0000000..64caeaa
--- /dev/null
+++ b/internal/components/data.go
@@ -0,0 +1,37 @@
+package components
+
+import (
+	"searchix/frontend"
+	"searchix/internal/config"
+	search "searchix/internal/index"
+	"searchix/internal/nix"
+
+	"github.com/blevesearch/bleve/v2"
+)
+
+type TemplateData struct {
+	Sources       []*config.Source
+	Source        config.Source
+	Query         string
+	Results       bool
+	SourceResult  *bleve.SearchResult
+	ExtraHeadHTML string
+	Code          int
+	Message       string
+	Assets        *frontend.AssetCollection
+}
+
+type ResultData struct {
+	TemplateData
+	Query          string
+	ResultsPerPage int
+	Results        *search.Result
+	Prev           string
+	Next           string
+}
+
+type DocumentData struct {
+	TemplateData
+	Document *nix.Importable
+	Children *search.Result
+}
diff --git a/internal/components/detail.templ b/internal/components/detail.templ
new file mode 100644
index 0000000..6d6710c
--- /dev/null
+++ b/internal/components/detail.templ
@@ -0,0 +1,20 @@
+package components
+
+import (
+	"searchix/internal/nix"
+)
+
+templ Detail(thing nix.Importable) {
+	switch thing.(type) {
+		case nix.Option:
+			@OptionDetail(thing.(nix.Option))
+		case nix.Package:
+			@PackageDetail(thing.(nix.Package))
+	}
+}
+
+templ DetailPage(tdata TemplateData, thing nix.Importable) {
+	@Page(tdata) {
+		@Detail(thing)
+	}
+}
diff --git a/internal/components/error.templ b/internal/components/error.templ
new file mode 100644
index 0000000..8e45095
--- /dev/null
+++ b/internal/components/error.templ
@@ -0,0 +1,18 @@
+package components
+
+import (
+	"strconv"
+)
+
+templ Error(tdata TemplateData) {
+	<p class="notice error">
+		{ strconv.Itoa(tdata.Code) }
+		{ tdata.Message }
+	</p>
+}
+
+templ ErrorPage(tdata TemplateData) {
+	@Page(tdata) {
+		@Error(tdata)
+	}
+}
diff --git a/internal/components/homepage.templ b/internal/components/homepage.templ
new file mode 100644
index 0000000..1cc2b9e
--- /dev/null
+++ b/internal/components/homepage.templ
@@ -0,0 +1,10 @@
+package components
+
+templ Homepage(tdata TemplateData) {
+	@Page(tdata) {
+		<p>
+			Search Nix Packages and options from NixOS, Darwin and Home-Manager
+		</p>
+		<a href="https://git.sr.ht/~alanpearce/searchix">Source code</a>
+	}
+}
diff --git a/internal/components/markdown.templ b/internal/components/markdown.templ
new file mode 100644
index 0000000..2a8787d
--- /dev/null
+++ b/internal/components/markdown.templ
@@ -0,0 +1,35 @@
+package components
+
+import (
+	"regexp"
+
+	"searchix/internal/nix"
+
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/extension"
+	"context"
+	"io"
+)
+
+var (
+	md = goldmark.New(
+		goldmark.WithExtensions(extension.NewLinkify()),
+	)
+	firstSentenceRegexp = regexp.MustCompile(`^.*?\.[[:space:]]`)
+)
+
+func firstSentence[T ~string](text T) T {
+	if fs := firstSentenceRegexp.FindString(string(text)); fs != "" {
+		return T(fs)
+	}
+
+	return text
+}
+
+func markdown(text nix.Markdown) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
+		err := md.Convert([]byte(text), w)
+
+		return err
+	})
+}
diff --git a/internal/components/optionDetail.templ b/internal/components/optionDetail.templ
new file mode 100644
index 0000000..52ce859
--- /dev/null
+++ b/internal/components/optionDetail.templ
@@ -0,0 +1,58 @@
+package components
+
+import "searchix/internal/nix"
+
+templ OptionDetail(option nix.Option) {
+	<h2>{ option.Name }</h2>
+	@markdown(option.Description)
+	<dl>
+		if option.Type != "" {
+			<dt>Type</dt>
+			<dd><code>{ option.Type }</code></dd>
+		}
+		if option.Default != nil {
+			if option.Default.Text != "" || option.Default.Markdown != "" {
+				<dt>Default</dt>
+				<dd>
+					if option.Default.Markdown != "" {
+						@markdown(option.Default.Markdown)
+					} else {
+						<pre><code>{ option.Default.Text }</code></pre>
+					}
+				</dd>
+			}
+		}
+		if option.Example != nil {
+			if option.Example.Text != "" || option.Example.Markdown != "" {
+				<dt>Example</dt>
+				<dd>
+					if option.Example.Markdown != "" {
+						@markdown(option.Example.Markdown)
+					} else {
+						<pre><code>{ option.Example.Text }</code></pre>
+					}
+				</dd>
+			}
+		}
+		if option.RelatedPackages != "" {
+			<dt>Related Packages</dt>
+			<dd>
+				@markdown(option.RelatedPackages)
+			</dd>
+		}
+		if len(option.Declarations) > 0 {
+			<dt>Declared</dt>
+			for _, d := range option.Declarations {
+				<dd>
+					<a href={ templ.SafeURL(d.URL) }>{ d.Name }</a>
+				</dd>
+			}
+		}
+	</dl>
+}
+
+templ OptionDetailPage(tdata TemplateData, option nix.Option) {
+	@Page(tdata) {
+		@OptionDetail(option)
+	}
+}
diff --git a/internal/components/options.templ b/internal/components/options.templ
new file mode 100644
index 0000000..726d328
--- /dev/null
+++ b/internal/components/options.templ
@@ -0,0 +1,34 @@
+package components
+
+import (
+	"searchix/internal/index"
+	"searchix/internal/nix"
+)
+
+templ Options(result *index.Result) {
+	<table>
+		<thead>
+			<tr>
+				<td scope="col">Title</td>
+				<td scope="col">Description</td>
+			</tr>
+		</thead>
+		<tbody>
+			for _, hit := range result.Hits {
+				@optionRow(hit.Data.(nix.Option))
+			}
+		</tbody>
+	</table>
+}
+
+templ optionRow(o nix.Option) {
+	<tr>
+		<td>
+			@openDialogLink(o.Name)
+		</td>
+		<td>
+			@markdown(firstSentence(o.Description))
+			<dialog id={ o.Name }></dialog>
+		</td>
+	</tr>
+}
diff --git a/internal/components/packageDetail.templ b/internal/components/packageDetail.templ
new file mode 100644
index 0000000..7b4a5cb
--- /dev/null
+++ b/internal/components/packageDetail.templ
@@ -0,0 +1,99 @@
+package components
+
+import (
+	"searchix/internal/nix"
+)
+
+func licenseName(l nix.License) string {
+	if l.FullName != "" {
+		return l.FullName
+	} else {
+		return l.Name
+	}
+}
+
+templ PackageDetail(pkg nix.Package) {
+	<h2>
+		if pkg.Broken {
+			<del>{ pkg.Attribute }</del>
+		} else {
+			{ pkg.Attribute }
+		}
+	</h2>
+	if pkg.LongDescription != "" {
+		@markdown(pkg.LongDescription)
+	} else {
+		<p>{ pkg.Description }</p>
+	}
+	<dl>
+		if pkg.MainProgram != "" {
+			<dt>Main Program</dt>
+			<dd>
+				<code>{ pkg.MainProgram }</code>
+			</dd>
+		}
+		if len(pkg.Homepages) > 0 {
+			<dt>Homepage</dt>
+			<dd>
+				<ul>
+					for _, u := range pkg.Homepages {
+						<li>
+							<a href={ templ.SafeURL(u) }>{ u }</a>
+						</li>
+					}
+				</ul>
+			</dd>
+		}
+		if pkg.Version != "" {
+			<dt>Version</dt>
+			<dd>{ pkg.Version }</dd>
+		}
+		if len(pkg.Licenses) > 0 {
+			<dt>License</dt>
+			<dd>
+				<ul>
+					for _, l := range pkg.Licenses {
+						<li>
+							if l.URL != "" {
+								<a href={ templ.SafeURL(l.URL) }>{ licenseName(l) }</a>
+							} else {
+								{ licenseName(l) }
+							}
+							if l.AppendixURL != "" {
+								<a href={ templ.SafeURL(l.AppendixURL) }>Appendix</a>
+							}
+						</li>
+					}
+				</ul>
+			</dd>
+		}
+		if len(pkg.Maintainers) > 0 {
+			<dt>Maintainers</dt>
+			<dd>
+				<ul>
+					for _, m := range pkg.Maintainers {
+						<li>
+							if m.Github != "" {
+								<a href={ joinPath("https://github.com", m.Github) }>{ m.Name }</a>
+							} else {
+								{ m.Name }
+							}
+						</li>
+					}
+				</ul>
+			</dd>
+		}
+		if pkg.Definition != "" {
+			<dt>Defined</dt>
+			<dd>
+				<a href={ templ.SafeURL(pkg.Definition) }>Source</a>
+			</dd>
+		}
+	</dl>
+}
+
+templ PackageDetailPage(tdata TemplateData, pkg nix.Package) {
+	@Page(tdata) {
+		@PackageDetail(pkg)
+	}
+}
diff --git a/internal/components/packages.templ b/internal/components/packages.templ
new file mode 100644
index 0000000..4e00a5a
--- /dev/null
+++ b/internal/components/packages.templ
@@ -0,0 +1,37 @@
+package components
+
+import (
+	"searchix/internal/index"
+	"searchix/internal/nix"
+)
+
+templ Packages(result *index.Result) {
+	<table>
+		<thead>
+			<tr>
+				<th scope="col">Attribute</th>
+				<th scope="col">Name</th>
+				<th scope="col">Description</th>
+			</tr>
+		</thead>
+		<tbody>
+			for _, hit := range result.Hits {
+				@packageRow(hit.Data.(nix.Package))
+			}
+		</tbody>
+	</table>
+}
+
+templ packageRow(p nix.Package) {
+	<tr>
+		<td>
+			@openDialogLink(p.Attribute)
+		</td>
+		<td>
+			{ p.Name }
+		</td>
+		<td>
+			{ p.Description }
+		</td>
+	</tr>
+}
diff --git a/internal/components/page.templ b/internal/components/page.templ
new file mode 100644
index 0000000..9b278e2
--- /dev/null
+++ b/internal/components/page.templ
@@ -0,0 +1,91 @@
+package components
+
+import (
+	"net/url"
+
+	"searchix/internal/config"
+	"searchix/frontend"
+)
+
+templ Page(tdata TemplateData) {
+	<!DOCTYPE html>
+	<html lang="en-GB">
+		<head>
+			<meta charset="utf-8"/>
+			<meta name="viewport" content="width=device-width, initial-scale=1"/>
+			<title>Searchix</title>
+			for _, sheet := range tdata.Assets.Stylesheets {
+				<link href={ sheet.URL } rel="stylesheet" integrity={ "sha256-" + sheet.Base64SHA256 }/>
+			}
+			@Unsafe(tdata.ExtraHeadHTML)
+			for _, source := range tdata.Sources {
+				<link
+					rel="search"
+					type="application/opensearchdescription+xml"
+					title={ "Searchix " + sourceNameAndType(*source) }
+					href={ string(joinPath("/", source.Importer.String(), source.Key, "opensearch.xml")) }
+				/>
+			}
+		</head>
+		<body>
+			<header>
+				<nav>
+					<h1><a href="/">Searchix</a></h1>
+					for _, source := range tdata.Sources {
+						<a
+							if tdata.Source.Name == source.Name {
+								class="current"
+								href={ joinPath("/", source.Importer.String(), source.Key, "search") }
+							} else {
+								href={ joinPathQuery(joinPath("/", source.Importer.String(), source.Key, "search"), tdata.Query) }
+							}
+						>{ source.Name }</a>
+					}
+				</nav>
+			</header>
+			<main>
+				{ children... }
+			</main>
+			<footer>
+				Made by <a href="https://alanpearce.eu">Alan Pearce</a>.
+				<a href="https://todo.sr.ht/~alanpearce/searchix">Report issues</a>
+			</footer>
+		</body>
+	</html>
+}
+
+templ script(s *frontend.Asset) {
+	<script src={ s.URL } defer integrity={ "sha256-" + s.Base64SHA256 }></script>
+}
+
+func Unsafe(html string) templ.Component {
+	return templ.ComponentFunc(func(_ context.Context, w io.Writer) (err error) {
+		_, err = io.WriteString(w, html)
+		return
+	})
+}
+
+func sourceNameAndType(source config.Source) string {
+	switch source.Importer {
+	case config.Options:
+		return source.Name + " " + source.Importer.String()
+	case config.Packages:
+		return source.Name
+	}
+	return ""
+}
+
+func joinPath(base string, parts ...string) templ.SafeURL {
+	u, err := url.JoinPath(base, parts...)
+	if err != nil {
+		panic(err)
+	}
+	return templ.SafeURL(u)
+}
+
+func joinPathQuery[T ~string](path T, query string) templ.SafeURL {
+	if query == "" {
+		return templ.SafeURL(path)
+	}
+	return templ.SafeURL(string(path) + "?query=" + url.QueryEscape(query))
+}
diff --git a/internal/components/results.templ b/internal/components/results.templ
new file mode 100644
index 0000000..3953cc3
--- /dev/null
+++ b/internal/components/results.templ
@@ -0,0 +1,44 @@
+package components
+
+import (
+	"strconv"
+	"searchix/internal/nix"
+)
+
+templ Results(r ResultData) {
+	if r.Query != "" {
+		if r.Results != nil && r.Results.Total > 0 {
+			switch r.Results.Hits[0].Data.(type) {
+				case nix.Option:
+					@Options(r.Results)
+				case nix.Package:
+					@Packages(r.Results)
+			}
+			<footer aria-label="pagination">
+				<nav id="pagination">
+					if r.Prev != "" {
+						<a class="button" href={ templ.SafeURL(r.Prev) } rel="prev">Prev</a>
+					}
+					if r.Next != "" {
+						<a class="button" href={ templ.SafeURL(r.Next) } rel="next">Next</a>
+					}
+				</nav>
+				<span role="status">{ strconv.FormatUint(r.Results.Total, 10) } results</span>
+			</footer>
+		} else {
+			<span role="status">Nothing found</span>
+		}
+	} else {
+		<br/>
+	}
+}
+
+templ ResultsPage(r ResultData) {
+	@SearchPage(r.TemplateData, r) {
+		@Results(r)
+	}
+}
+
+templ openDialogLink(attr string) {
+	<a class="open-dialog" href={ templ.SafeURL(attr) }>{ attr }</a>
+}
diff --git a/internal/components/search.templ b/internal/components/search.templ
new file mode 100644
index 0000000..2cae754
--- /dev/null
+++ b/internal/components/search.templ
@@ -0,0 +1,34 @@
+package components
+
+templ Search(tdata TemplateData, r ResultData) {
+	<form id="search" role="search">
+		<fieldset>
+			<legend id="legend">
+				<h2>{ sourceNameAndType(tdata.Source) } search</h2>
+			</legend>
+			<input
+				id="query"
+				aria-labelledby="legend"
+				name="query"
+				type="search"
+				value={ r.Query }
+				autofocus
+				spellcheck="false"
+			/>
+			<button>Search</button>
+		</fieldset>
+	</form>
+}
+
+templ SearchPage(tdata TemplateData, r ResultData) {
+	@Page(tdata) {
+		@script(tdata.Assets.ByPath["/static/search.js"])
+		@Search(tdata, r)
+		<section id="results" role="list" aria-label="search results">
+			{ children... }
+		</section>
+		<dialog id="dialog">
+			<button autofocus>Close</button>
+		</dialog>
+	}
+}
diff --git a/internal/config/default.go b/internal/config/default.go
index 5b924a9..9a0c670 100644
--- a/internal/config/default.go
+++ b/internal/config/default.go
@@ -53,6 +53,7 @@ var DefaultConfig = Config{
 		Sources: map[string]*Source{
 			"nixos": {
 				Name:       "NixOS",
+				Order:      0,
 				Key:        "nixos",
 				Enable:     true,
 				Importer:   Options,
@@ -67,6 +68,7 @@ var DefaultConfig = Config{
 			},
 			"darwin": {
 				Name:       "Darwin",
+				Order:      1,
 				Key:        "darwin",
 				Enable:     false,
 				Importer:   Options,
@@ -85,6 +87,7 @@ var DefaultConfig = Config{
 			},
 			"home-manager": {
 				Name:       "Home Manager",
+				Order:      2,
 				Key:        "home-manager",
 				Enable:     false,
 				Importer:   Options,
@@ -103,6 +106,7 @@ var DefaultConfig = Config{
 			},
 			"nixpkgs": {
 				Name:       "Nix Packages",
+				Order:      3,
 				Key:        "nixpkgs",
 				Enable:     true,
 				Importer:   Packages,
diff --git a/internal/config/structs.go b/internal/config/structs.go
index 70283f2..6c6bc13 100644
--- a/internal/config/structs.go
+++ b/internal/config/structs.go
@@ -4,7 +4,6 @@ package config
 // keep config structs here so that lll ignores the long lines (go doesn't support multi-line struct tags)
 
 import (
-	"html/template"
 	"log/slog"
 )
 
@@ -22,7 +21,7 @@ type Web struct {
 	BaseURL               URL               `comment:"Absolute URL to this instance, useful if behind a reverse proxy"`
 	SentryDSN             string            `comment:"If set, will send server errors to Sentry"`
 	Environment           string            `comment:"Affects logging parameters. One of 'development' or 'production'"`
-	ExtraHeadHTML         template.HTML     `comment:"Content to add to HTML <head>. Can be used to override styling, add scripts, etc."`
+	ExtraHeadHTML         string            `comment:"Content to add to HTML <head>. Can be used to override styling, add scripts, etc."`
 	Headers               map[string]string `comment:"Extra headers to send with HTTP requests"`
 }
 
@@ -35,6 +34,7 @@ type Importer struct {
 
 type Source struct {
 	Name       string       `comment:"Human-readable name of source for generating links"`
+	Order      uint         `comment:"Order in which to show source in web interface."`
 	Key        string       `comment:"Machine-readable name of source. Must be URL- and path-safe."`
 	Enable     bool         `comment:"Controls whether to show in the web interface and to run fetch/import jobs."`
 	Fetcher    Fetcher      `comment:"How to fetch options.json. One of 'channel', 'channel-nixpkgs' or 'download'."`
diff --git a/internal/server/error.go b/internal/server/error.go
index e700d3b..4a8acbc 100644
--- a/internal/server/error.go
+++ b/internal/server/error.go
@@ -3,6 +3,8 @@ package server
 import (
 	"log/slog"
 	"net/http"
+
+	"searchix/internal/components"
 	"searchix/internal/config"
 )
 
@@ -14,9 +16,9 @@ func createErrorHandler(
 		if message == "" {
 			message = http.StatusText(code)
 		}
-		indexData := TemplateData{
+		indexData := components.TemplateData{
 			ExtraHeadHTML: config.Web.ExtraHeadHTML,
-			Sources:       config.Importer.Sources,
+			Sources:       sources,
 			Code:          code,
 			Message:       message,
 		}
@@ -24,9 +26,9 @@ func createErrorHandler(
 		w.Header().Del("Vary")
 		w.WriteHeader(code)
 		if r.Header.Get("Fetch") == "true" {
-			err = templates["error"].ExecuteTemplate(w, "main", indexData)
+			err = components.Error(indexData).Render(r.Context(), w)
 		} else {
-			err = templates["error"].Execute(w, indexData)
+			err = components.ErrorPage(indexData).Render(r.Context(), w)
 		}
 		if err != nil {
 			slog.Error(
diff --git a/internal/server/mux.go b/internal/server/mux.go
index 79e24cd..89ce952 100644
--- a/internal/server/mux.go
+++ b/internal/server/mux.go
@@ -3,7 +3,6 @@ package server
 import (
 	"context"
 	"fmt"
-	"html/template"
 	"io"
 	"log"
 	"log/slog"
@@ -16,11 +15,10 @@ import (
 	"time"
 
 	"searchix/frontend"
+	"searchix/internal/components"
 	"searchix/internal/config"
 	search "searchix/internal/index"
-	"searchix/internal/nix"
 
-	"github.com/blevesearch/bleve/v2"
 	sentryhttp "github.com/getsentry/sentry-go/http"
 	"github.com/osdevisnot/sorvor/pkg/livereload"
 	"github.com/pkg/errors"
@@ -33,36 +31,10 @@ type HTTPError struct {
 	Code    int
 }
 
-const jsSnippet = template.HTML(livereload.JsSnippet) // #nosec G203
-
-type TemplateData struct {
-	Sources       map[string]*config.Source
-	Source        config.Source
-	Query         string
-	Results       bool
-	SourceResult  *bleve.SearchResult
-	ExtraHeadHTML template.HTML
-	Code          int
-	Message       string
-	Assets        *frontend.AssetCollection
-}
-
-type ResultData struct {
-	TemplateData
-	Query          string
-	ResultsPerPage int
-	Results        *search.Result
-	Prev           string
-	Next           string
-}
-
-type DocumentData struct {
-	TemplateData
-	Document *nix.Importable
-	Children *search.Result
-}
-
-var templates TemplateCollection
+var (
+	templates TemplateCollection
+	sources   []*config.Source
+)
 
 func applyDevModeOverrides(cfg *config.Config) {
 	if len(cfg.Web.ContentSecurityPolicy.ScriptSrc) == 0 {
@@ -70,10 +42,18 @@ func applyDevModeOverrides(cfg *config.Config) {
 	}
 	cfg.Web.ContentSecurityPolicy.ScriptSrc = append(
 		cfg.Web.ContentSecurityPolicy.ScriptSrc,
+		"http://localhost:7331",
 		"'unsafe-inline'",
 	)
 }
 
+func sortSources(ss map[string]*config.Source) {
+	sources = make([]*config.Source, len(ss))
+	for _, v := range ss {
+		sources[v.Order] = v
+	}
+}
+
 func NewMux(
 	cfg *config.Config,
 	index *search.ReadIndex,
@@ -93,19 +73,20 @@ func NewMux(
 	if err != nil {
 		log.Panicf("could not load templates: %v", err)
 	}
+	sortSources(cfg.Importer.Sources)
 
 	errorHandler := createErrorHandler(cfg)
 
 	top := http.NewServeMux()
 	mux := http.NewServeMux()
 	mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) {
-		indexData := TemplateData{
+		indexData := components.TemplateData{
 			ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
-			Sources:       cfg.Importer.Sources,
+			Sources:       sources,
 			Assets:        frontend.Assets,
 		}
 		w.Header().Add("Cache-Control", "max-age=86400")
-		err := templates["index"].Execute(w, indexData)
+		err := components.Homepage(indexData).Render(r.Context(), w)
 		if err != nil {
 			errorHandler(w, r, err.Error(), http.StatusInternalServerError)
 		}
@@ -146,12 +127,13 @@ func NewMux(
 					errorHandler(w, r, err.Error(), http.StatusInternalServerError)
 				}
 
-				tdata := ResultData{
-					TemplateData: TemplateData{
+				tdata := components.ResultData{
+					TemplateData: components.TemplateData{
 						ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
 						Source:        *source,
-						Sources:       cfg.Importer.Sources,
+						Sources:       sources,
 						Assets:        frontend.Assets,
+						Query:         qs,
 					},
 					ResultsPerPage: search.ResultsPerPage,
 					Query:          qs,
@@ -193,9 +175,9 @@ func NewMux(
 				w.Header().Add("Vary", "Fetch")
 				if r.Header.Get("Fetch") == "true" {
 					w.Header().Add("Content-Type", "text/html; charset=utf-8")
-					err = templates[importerType.String()].ExecuteTemplate(w, "results", tdata)
+					err = components.Results(tdata).Render(r.Context(), w)
 				} else {
-					err = templates[importerType.String()].Execute(w, tdata)
+					err = components.ResultsPage(tdata).Render(r.Context(), w)
 				}
 				if err != nil {
 					slog.Error("template error", "template", importerType, "error", err)
@@ -210,13 +192,16 @@ func NewMux(
 				}
 
 				w.Header().Add("Cache-Control", "max-age=14400")
-				err = templates["search"].Execute(w, TemplateData{
-					ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
-					Sources:       cfg.Importer.Sources,
-					Source:        *source,
-					SourceResult:  sourceResult,
-					Assets:        frontend.Assets,
-				})
+				err = components.SearchPage(
+					components.TemplateData{
+						ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
+						Sources:       sources,
+						Source:        *source,
+						SourceResult:  sourceResult,
+						Assets:        frontend.Assets,
+					},
+					components.ResultData{},
+				).Render(r.Context(), w)
 				if err != nil {
 					errorHandler(w, r, err.Error(), http.StatusInternalServerError)
 
@@ -260,20 +245,20 @@ func NewMux(
 				return
 			}
 
-			tdata := DocumentData{
-				TemplateData: TemplateData{
+			tdata := components.DocumentData{
+				TemplateData: components.TemplateData{
 					ExtraHeadHTML: cfg.Web.ExtraHeadHTML,
 					Source:        *source,
-					Sources:       cfg.Importer.Sources,
+					Sources:       sources,
 					Assets:        frontend.Assets,
 				},
 				Document: doc,
 			}
 			if r.Header.Get("Fetch") == "true" {
 				w.Header().Add("Content-Type", "text/html; charset=utf-8")
-				err = templates[importerSingular].ExecuteTemplate(w, "main", tdata)
+				err = components.Detail(*doc).Render(r.Context(), w)
 			} else {
-				err = templates[importerSingular].Execute(w, tdata)
+				err = components.DetailPage(tdata.TemplateData, *doc).Render(r.Context(), w)
 			}
 			if err != nil {
 				slog.Error("template error", "template", importerSingular, "error", err)
@@ -337,7 +322,7 @@ func NewMux(
 
 	if liveReload {
 		applyDevModeOverrides(cfg)
-		cfg.Web.ExtraHeadHTML = jsSnippet
+		cfg.Web.ExtraHeadHTML = livereload.JsSnippet
 		liveReload := livereload.New()
 		liveReload.Start()
 		top.Handle("/livereload", liveReload)
diff --git a/internal/server/templates.go b/internal/server/templates.go
index 38ff5d4..fa95425 100644
--- a/internal/server/templates.go
+++ b/internal/server/templates.go
@@ -95,21 +95,13 @@ func loadTemplates() (TemplateCollection, error) {
 	templateDir := "templates"
 	templates := make(TemplateCollection, 0)
 
-	layoutFile := path.Join(templateDir, "index.gotmpl")
-
-	index, err := loadTemplate(layoutFile)
-	if err != nil {
-		return nil, err
-	}
-	templates["index"] = index
-
 	glob := path.Join(templateDir, "*.gotmpl")
 	templatePaths, err := fs.Glob(frontend.Files, glob)
 	if err != nil {
 		return nil, errors.WithMessage(err, "could not glob main templates")
 	}
 	for _, fullname := range templatePaths {
-		tpl, err := loadTemplate(layoutFile, fullname)
+		tpl, err := loadTemplate(fullname)
 		if err != nil {
 			return nil, err
 		}
@@ -117,20 +109,5 @@ func loadTemplates() (TemplateCollection, error) {
 		templates[name] = tpl
 	}
 
-	glob = path.Join(templateDir, "blocks", "*.gotmpl")
-	templatePaths, err = fs.Glob(frontend.Files, glob)
-	if err != nil {
-		return nil, errors.WithMessage(err, "could not glob block templates")
-	}
-	for _, fullname := range templatePaths {
-		tpl, err := loadTemplate(layoutFile, glob, fullname)
-		if err != nil {
-			return nil, err
-		}
-
-		name, _ := strings.CutSuffix(path.Base(fullname), ".gotmpl")
-		templates[name] = tpl
-	}
-
 	return templates, nil
 }
diff --git a/modd.conf b/modd.conf
index 8fced57..ab01682 100644
--- a/modd.conf
+++ b/modd.conf
@@ -1,3 +1,4 @@
-**/*.go config.toml {
-  daemon: wgo run -exit ./cmd/searchix-web --live --config config.toml
+**/*.go !**/*_templ.go config.toml {
+  daemon +sigint: templ generate --watch --proxy="http://localhost:3000" --open-browser=false \
+    --cmd="go run ./cmd/searchix-web --live --config config.toml"
 }
diff --git a/nix/dev-shell.nix b/nix/dev-shell.nix
index 58bf467..eb2021f 100644
--- a/nix/dev-shell.nix
+++ b/nix/dev-shell.nix
@@ -11,6 +11,7 @@ mkShell {
   packages = with pkgs;  [
     goEnv
 
+    templ
     modd
     brotli
     bleve
diff --git a/nix/package.nix b/nix/package.nix
index bb5a255..068e5e1 100644
--- a/nix/package.nix
+++ b/nix/package.nix
@@ -37,6 +37,7 @@ buildGoApplication {
   patchPhase = ''
     rm -f frontend/static/base.css
     cp ${css} frontend/static/base.css
+    ${pkgs.templ}/bin/templ generate
   '';
   tags = [ "embed" ];
   ldflags = [