about summary refs log tree commit diff stats
path: root/internal
diff options
context:
space:
mode:
authorAlan Pearce2024-06-21 13:02:08 +0200
committerAlan Pearce2024-06-21 15:33:38 +0200
commitfc5fd2edd9b8282497e33a18300eab694d8a89c6 (patch)
tree18af097c037ef781cc8f6148d7c1ba37e10877c1 /internal
parentcac323d9ae70f55a43fd99b73e60cf614be11797 (diff)
downloadsearchix-fc5fd2edd9b8282497e33a18300eab694d8a89c6.tar.lz
searchix-fc5fd2edd9b8282497e33a18300eab694d8a89c6.tar.zst
searchix-fc5fd2edd9b8282497e33a18300eab694d8a89c6.zip
refactor: switch to templ for HTML templates
Diffstat (limited to 'internal')
-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
17 files changed, 569 insertions, 84 deletions
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..f1a5b8b
--- /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.Scripts["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
 }