about summary refs log tree commit diff stats
path: root/internal
diff options
context:
space:
mode:
authorAlan Pearce2025-03-18 22:40:46 +0100
committerAlan Pearce2025-03-19 17:33:58 +0100
commit896d844cac976afd0ee8aa73dd2fb28e15e7ac79 (patch)
treecc8d288d0039cb3d2084f43cafe8d4e0aea50e8b /internal
parent1183108baa44fde88944e9207fb7763668c2b448 (diff)
downloadsearchix-896d844cac976afd0ee8aa73dd2fb28e15e7ac79.tar.lz
searchix-896d844cac976afd0ee8aa73dd2fb28e15e7ac79.tar.zst
searchix-896d844cac976afd0ee8aa73dd2fb28e15e7ac79.zip
feat: Convert templ components to gomponents
Diffstat (limited to 'internal')
-rw-r--r--internal/components/combined.go56
-rw-r--r--internal/components/combined.templ47
-rw-r--r--internal/components/data.go10
-rw-r--r--internal/components/detail.go22
-rw-r--r--internal/components/detail.templ20
-rw-r--r--internal/components/dev.go23
-rw-r--r--internal/components/dev.templ18
-rw-r--r--internal/components/error.go17
-rw-r--r--internal/components/error.templ18
-rw-r--r--internal/components/markdown.go15
-rw-r--r--internal/components/markdown.templ33
-rw-r--r--internal/components/optionDetail.go71
-rw-r--r--internal/components/optionDetail.templ58
-rw-r--r--internal/components/options.go50
-rw-r--r--internal/components/options.templ45
-rw-r--r--internal/components/packageDetail.go120
-rw-r--r--internal/components/packageDetail.templ109
-rw-r--r--internal/components/packages.go53
-rw-r--r--internal/components/packages.templ48
-rw-r--r--internal/components/page.go152
-rw-r--r--internal/components/page.templ123
-rw-r--r--internal/components/results.go65
-rw-r--r--internal/components/results.templ64
-rw-r--r--internal/components/search.go63
-rw-r--r--internal/components/search.templ44
-rw-r--r--internal/nix/option.go17
-rw-r--r--internal/server/error.go4
-rw-r--r--internal/server/mux.go12
28 files changed, 741 insertions, 636 deletions
diff --git a/internal/components/combined.go b/internal/components/combined.go
new file mode 100644
index 0000000..d8c6fea
--- /dev/null
+++ b/internal/components/combined.go
@@ -0,0 +1,56 @@
+package components
+
+import (
+	"go.alanpearce.eu/searchix/internal/config"
+	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/searchix/internal/nix"
+
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func CombinedData(data nix.Importable) g.Node {
+	switch data.(type) {
+	case nix.Option:
+		if o := convertMatch[nix.Option](data); o != nil {
+			return firstSentence(o.Description)
+		}
+	case nix.Package:
+		if p := convertMatch[nix.Package](data); p != nil {
+			return g.Text(firstSentence(p.Description))
+		}
+	}
+
+	return g.Text("")
+}
+
+func Combined(result *index.Result) g.Node {
+	return Table(
+		THead(
+			Tr(
+				Th(Scope("col"), g.Text("Attribute")),
+				Th(Scope("col"), g.Text("Description")),
+				g.If(config.DevMode,
+					Th(Scope("col"), g.Text("Score")),
+				),
+			),
+		),
+		TBody(
+			g.Map(result.Hits, func(hit index.DocumentMatch) g.Node {
+				return Tr(
+					Td(
+						openCombinedDialogLink(nix.GetKey(hit.Data)),
+					),
+					Td(
+						CombinedData(hit.Data),
+					),
+					g.If(config.DevMode,
+						Td(
+							Score(hit),
+						),
+					),
+				)
+			}),
+		),
+	)
+}
diff --git a/internal/components/combined.templ b/internal/components/combined.templ
deleted file mode 100644
index 3beddcd..0000000
--- a/internal/components/combined.templ
+++ /dev/null
@@ -1,47 +0,0 @@
-package components
-
-import (
-	"go.alanpearce.eu/searchix/internal/config"
-	"go.alanpearce.eu/searchix/internal/index"
-	"go.alanpearce.eu/searchix/internal/nix"
-)
-
-templ Combined(result *index.Result) {
-	<table>
-		<thead>
-			<tr>
-				<th scope="col">Attribute</th>
-				<th scope="col">Description</th>
-				if config.DevMode {
-					<th scope="col">Score</th>
-				}
-			</tr>
-		</thead>
-		<tbody>
-			for _, hit := range result.Hits {
-				<tr>
-					<td>
-						@openCombinedDialogLink(nix.GetKey(hit.Data))
-					</td>
-					<td>
-						switch hit.Data.(type) {
-							case nix.Option:
-								if o := convertMatch[nix.Option](hit.Data); o != nil {
-									@markdown(firstSentence(o.Description))
-								}
-							case nix.Package:
-								if o := convertMatch[nix.Package](hit.Data); o != nil {
-									{ firstSentence(o.Description) }
-								}
-						}
-					</td>
-					if config.DevMode {
-						<td>
-							@score(hit)
-						</td>
-					}
-				</tr>
-			}
-		</tbody>
-	</table>
-}
diff --git a/internal/components/data.go b/internal/components/data.go
index c34dfb8..977b90e 100644
--- a/internal/components/data.go
+++ b/internal/components/data.go
@@ -4,6 +4,7 @@ import (
 	"go.alanpearce.eu/searchix/frontend"
 	"go.alanpearce.eu/searchix/internal/config"
 	search "go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/searchix/internal/nix"
 )
 
 type TemplateData struct {
@@ -24,3 +25,12 @@ type ResultData struct {
 	Next    string
 	All     string
 }
+
+func convertMatch[I nix.Importable](m nix.Importable) *I {
+	i, ok := m.(I)
+	if !ok {
+		return nil
+	}
+
+	return &i
+}
diff --git a/internal/components/detail.go b/internal/components/detail.go
new file mode 100644
index 0000000..e6164fa
--- /dev/null
+++ b/internal/components/detail.go
@@ -0,0 +1,22 @@
+package components
+
+import (
+	"go.alanpearce.eu/searchix/internal/nix"
+
+	g "go.alanpearce.eu/gomponents"
+)
+
+func Detail(thing nix.Importable) g.Node {
+	switch t := thing.(type) {
+	case nix.Option:
+		return OptionDetail(t)
+	case nix.Package:
+		return PackageDetail(t)
+	default:
+		return nil
+	}
+}
+
+func DetailPage(tdata TemplateData, thing nix.Importable) g.Node {
+	return Page(tdata, Detail(thing))
+}
diff --git a/internal/components/detail.templ b/internal/components/detail.templ
deleted file mode 100644
index fa7206c..0000000
--- a/internal/components/detail.templ
+++ /dev/null
@@ -1,20 +0,0 @@
-package components
-
-import (
-	"go.alanpearce.eu/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/dev.go b/internal/components/dev.go
new file mode 100644
index 0000000..3d77094
--- /dev/null
+++ b/internal/components/dev.go
@@ -0,0 +1,23 @@
+package components
+
+import (
+	"strconv"
+
+	"go.alanpearce.eu/searchix/internal/index"
+
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func Score(h index.DocumentMatch) g.Node {
+	return g.Group([]g.Node{
+		A(
+			Class("open-sibling-dialog"),
+			g.Text(strconv.FormatFloat(h.Score, 'f', 2, 64)),
+		),
+		Dialog(
+			Button(AutoFocus(), g.Text("Close")),
+			Pre(g.Text(h.Expl.String())),
+		),
+	})
+}
diff --git a/internal/components/dev.templ b/internal/components/dev.templ
deleted file mode 100644
index a03eb4b..0000000
--- a/internal/components/dev.templ
+++ /dev/null
@@ -1,18 +0,0 @@
-package components
-
-import (
-	"go.alanpearce.eu/searchix/internal/index"
-	"strconv"
-)
-
-templ score(h index.DocumentMatch) {
-	<a class="open-sibling-dialog">
-		{ strconv.FormatFloat(h.Score, 'f', 2, 64) }
-	</a>
-	<dialog>
-		<button autofocus>Close</button>
-		<pre>
-			{ h.Expl.String() }
-		</pre>
-	</dialog>
-}
diff --git a/internal/components/error.go b/internal/components/error.go
new file mode 100644
index 0000000..fe34919
--- /dev/null
+++ b/internal/components/error.go
@@ -0,0 +1,17 @@
+package components
+
+import (
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func Error(tdata TemplateData) g.Node {
+	return P(
+		Class("notice error"),
+		g.Textf("%d %s", tdata.Code, tdata.Message),
+	)
+}
+
+func ErrorPage(tdata TemplateData) g.Node {
+	return Page(tdata, Error(tdata))
+}
diff --git a/internal/components/error.templ b/internal/components/error.templ
deleted file mode 100644
index 8e45095..0000000
--- a/internal/components/error.templ
+++ /dev/null
@@ -1,18 +0,0 @@
-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/markdown.go b/internal/components/markdown.go
new file mode 100644
index 0000000..405ab52
--- /dev/null
+++ b/internal/components/markdown.go
@@ -0,0 +1,15 @@
+package components
+
+import (
+	"regexp"
+)
+
+var firstSentenceRegexp = regexp.MustCompile(`^.*?\.[[:space:]]`)
+
+func firstSentence[T ~string](text T) T {
+	if fs := firstSentenceRegexp.FindString(string(text)); fs != "" {
+		return T(fs)
+	}
+
+	return text
+}
diff --git a/internal/components/markdown.templ b/internal/components/markdown.templ
deleted file mode 100644
index 21b0aa0..0000000
--- a/internal/components/markdown.templ
+++ /dev/null
@@ -1,33 +0,0 @@
-package components
-
-import (
-	"context"
-	"io"
-	"regexp"
-
-	"github.com/yuin/goldmark"
-	"github.com/yuin/goldmark/extension"
-
-	"go.alanpearce.eu/searchix/internal/nix"
-)
-
-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 {
-		return md.Convert([]byte(text), w)
-	})
-}
diff --git a/internal/components/optionDetail.go b/internal/components/optionDetail.go
new file mode 100644
index 0000000..d5f0c24
--- /dev/null
+++ b/internal/components/optionDetail.go
@@ -0,0 +1,71 @@
+package components
+
+import (
+	"go.alanpearce.eu/searchix/internal/nix"
+
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func OptionDetail(option nix.Option) g.Node {
+	return g.Group([]g.Node{
+		H2(g.Text(option.Name)),
+		option.Description,
+		Dl(
+			g.If(option.Type != "",
+				g.Group([]g.Node{
+					Dt(g.Text("Type")),
+					Dd(Code(g.Text(option.Type))),
+				}),
+			),
+			g.Iff(option.Default != nil,
+				func() g.Node {
+					return g.Group([]g.Node{
+						Dt(g.Text("Default")),
+						Dd(
+							g.If(option.Default.Markdown != "",
+								option.Default.Markdown,
+								Pre(Code(g.Text(option.Default.Text))),
+							),
+						),
+					})
+				},
+			),
+			g.Iff(option.Example != nil,
+				func() g.Node {
+					return g.Group([]g.Node{
+						Dt(g.Text("Example")),
+						Dd(
+							g.If(option.Example.Markdown != "",
+								option.Example.Markdown,
+								Pre(Code(g.Text(option.Example.Text))),
+							),
+						),
+					})
+				},
+			),
+			g.If(option.RelatedPackages != "",
+				g.Group([]g.Node{
+					Dt(g.Text("Related Packages")),
+					Dd(
+						option.RelatedPackages,
+					),
+				}),
+			),
+			g.If(len(option.Declarations) > 0,
+				g.Group([]g.Node{
+					Dt(g.Text("Declared")),
+					g.Map(option.Declarations, func(d nix.Link) g.Node {
+						return Dd(
+							A(Href(d.URL), g.Text(d.Name)),
+						)
+					}),
+				}),
+			),
+		),
+	})
+}
+
+func OptionDetailPage(tdata TemplateData, option nix.Option) g.Node {
+	return Page(tdata, OptionDetail(option))
+}
diff --git a/internal/components/optionDetail.templ b/internal/components/optionDetail.templ
deleted file mode 100644
index 6eaafb4..0000000
--- a/internal/components/optionDetail.templ
+++ /dev/null
@@ -1,58 +0,0 @@
-package components
-
-import "go.alanpearce.eu/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.go b/internal/components/options.go
new file mode 100644
index 0000000..af6c73f
--- /dev/null
+++ b/internal/components/options.go
@@ -0,0 +1,50 @@
+package components
+
+import (
+	"go.alanpearce.eu/searchix/internal/config"
+	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/searchix/internal/nix"
+
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func Options(result *index.Result) g.Node {
+	return Table(
+		THead(
+			Tr(
+				Th(Scope("col"), g.Text("Title")),
+				Th(Scope("col"), g.Text("Description")),
+				g.If(config.DevMode,
+					Th(Scope("col"), g.Text("Score")),
+				),
+			),
+		),
+		TBody(
+			g.Map(result.Hits, func(hit index.DocumentMatch) g.Node {
+				if m := convertMatch[nix.Option](hit.Data); m != nil {
+					return optionRow(hit, *m)
+				}
+
+				return nil
+			}),
+		),
+	)
+}
+
+func optionRow(hit index.DocumentMatch, o nix.Option) g.Node {
+	return Tr(
+		Td(
+			openDialogLink(o.Name),
+		),
+		Td(
+			firstSentence(o.Description),
+			Dialog(ID(o.Name)),
+		),
+		g.If(config.DevMode,
+			Td(
+				Score(hit),
+			),
+		),
+	)
+}
diff --git a/internal/components/options.templ b/internal/components/options.templ
deleted file mode 100644
index 097f66f..0000000
--- a/internal/components/options.templ
+++ /dev/null
@@ -1,45 +0,0 @@
-package components
-
-import (
-	"go.alanpearce.eu/searchix/internal/config"
-	"go.alanpearce.eu/searchix/internal/index"
-	"go.alanpearce.eu/searchix/internal/nix"
-)
-
-templ Options(result *index.Result) {
-	<table>
-		<thead>
-			<tr>
-				<th scope="col">Title</th>
-				<th scope="col">Description</th>
-				if config.DevMode {
-					<th scope="col">Score</th>
-				}
-			</tr>
-		</thead>
-		<tbody>
-			for _, hit := range result.Hits {
-				if m := convertMatch[nix.Option](hit.Data); m != nil {
-					@optionRow(hit, *m)
-				}
-			}
-		</tbody>
-	</table>
-}
-
-templ optionRow(hit index.DocumentMatch, o nix.Option) {
-	<tr>
-		<td>
-			@openDialogLink(o.Name)
-		</td>
-		<td>
-			@markdown(firstSentence(o.Description))
-			<dialog id={ o.Name }></dialog>
-		</td>
-		if config.DevMode {
-			<td>
-				@score(hit)
-			</td>
-		}
-	</tr>
-}
diff --git a/internal/components/packageDetail.go b/internal/components/packageDetail.go
new file mode 100644
index 0000000..01b1f4d
--- /dev/null
+++ b/internal/components/packageDetail.go
@@ -0,0 +1,120 @@
+package components
+
+import (
+	"go.alanpearce.eu/searchix/internal/nix"
+
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func licenseName(l nix.License) string {
+	if l.FullName != "" {
+		return l.FullName
+	}
+
+	return l.Name
+}
+
+func PackageDetail(pkg nix.Package) g.Node {
+	return g.Group([]g.Node{
+		H2(
+			g.If(pkg.Broken,
+				Del(g.Text(pkg.Attribute)),
+				g.Text(pkg.Attribute),
+			),
+		),
+		g.If(pkg.LongDescription != "",
+			pkg.LongDescription,
+			P(g.Text(pkg.Description)),
+		),
+		Dl(
+			g.If(pkg.MainProgram != "",
+				g.Group([]g.Node{
+					Dt(g.Text("Main Program")),
+					Dd(Code(g.Text(pkg.MainProgram))),
+				}),
+			),
+			g.If(len(pkg.Programs) > 0,
+				g.Group([]g.Node{
+					Dt(g.Text("Programs")),
+					Dd(
+						Ul(
+							g.Map(pkg.Programs, func(p string) g.Node {
+								return Li(Code(g.Text(p)))
+							}),
+						),
+					),
+				}),
+			),
+			g.If(len(pkg.Homepages) > 0,
+				g.Group([]g.Node{
+					Dt(g.Text("Homepage")),
+					Dd(
+						Ul(
+							g.Map(pkg.Homepages, func(u string) g.Node {
+								return Li(A(Href(u), g.Text(u)))
+							}),
+						),
+					),
+				}),
+			),
+			g.If(pkg.Version != "",
+				g.Group([]g.Node{
+					Dt(g.Text("Version")),
+					Dd(g.Text(pkg.Version)),
+				}),
+			),
+			g.If(len(pkg.Licenses) > 0,
+				g.Group([]g.Node{
+					Dt(g.Text("License")),
+					Dd(
+						Ul(
+							g.Map(pkg.Licenses, func(l nix.License) g.Node {
+								return Li(
+									g.If(l.URL != "",
+										A(Href(l.URL), g.Text(licenseName(l))),
+										g.Text(licenseName(l)),
+									),
+									g.If(l.AppendixURL != "",
+										A(Href(l.AppendixURL), g.Text("Appendix")),
+									),
+								)
+							}),
+						),
+					),
+				}),
+			),
+			g.If(len(pkg.Maintainers) > 0,
+				g.Group([]g.Node{
+					Dt(g.Text("Maintainers")),
+					Dd(
+						Ul(
+							g.Map(pkg.Maintainers, func(m nix.Maintainer) g.Node {
+								return Li(
+									g.If(
+										m.Github != "",
+										A(
+											Href(joinPath("https://github.com", m.Github)),
+											g.Text(m.Name),
+										),
+										g.Text(m.Name),
+									),
+								)
+							}),
+						),
+					),
+				}),
+			),
+			g.If(pkg.Definition != "",
+				g.Group([]g.Node{
+					Dt(g.Text("Defined")),
+					Dd(A(Href(pkg.Definition), g.Text("Source"))),
+				}),
+			),
+		),
+	})
+}
+
+func PackageDetailPage(tdata TemplateData, pkg nix.Package) g.Node {
+	return Page(tdata, PackageDetail(pkg))
+}
diff --git a/internal/components/packageDetail.templ b/internal/components/packageDetail.templ
deleted file mode 100644
index 84d2bdf..0000000
--- a/internal/components/packageDetail.templ
+++ /dev/null
@@ -1,109 +0,0 @@
-package components
-
-import "go.alanpearce.eu/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.Programs) > 0 {
-			<dt>Programs</dt>
-			<dd>
-				<ul>
-					for _, p := range pkg.Programs {
-						<li>
-							<code>{ p }</code>
-						</li>
-					}
-				</ul>
-			</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.go b/internal/components/packages.go
new file mode 100644
index 0000000..9bc3f99
--- /dev/null
+++ b/internal/components/packages.go
@@ -0,0 +1,53 @@
+package components
+
+import (
+	"go.alanpearce.eu/searchix/internal/config"
+	"go.alanpearce.eu/searchix/internal/index"
+	"go.alanpearce.eu/searchix/internal/nix"
+
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func Packages(result *index.Result) g.Node {
+	return Table(
+		THead(
+			Tr(
+				Th(Scope("col"), g.Text("Attribute")),
+				Th(Scope("col"), g.Text("Name")),
+				Th(Scope("col"), g.Text("Description")),
+				g.If(config.DevMode,
+					Th(Scope("col"), g.Text("Score")),
+				),
+			),
+		),
+		TBody(
+			g.Map(result.Hits, func(hit index.DocumentMatch) g.Node {
+				if m := convertMatch[nix.Package](hit.Data); m != nil {
+					return packageRow(hit, *m)
+				}
+
+				return nil
+			}),
+		),
+	)
+}
+
+func packageRow(hit index.DocumentMatch, p nix.Package) g.Node {
+	return Tr(
+		Td(
+			openDialogLink(p.Attribute),
+		),
+		Td(
+			g.Text(p.Name),
+		),
+		Td(
+			g.Text(p.Description),
+		),
+		g.If(config.DevMode,
+			Td(
+				Score(hit),
+			),
+		),
+	)
+}
diff --git a/internal/components/packages.templ b/internal/components/packages.templ
deleted file mode 100644
index 6e14026..0000000
--- a/internal/components/packages.templ
+++ /dev/null
@@ -1,48 +0,0 @@
-package components
-
-import (
-	"go.alanpearce.eu/searchix/internal/config"
-	"go.alanpearce.eu/searchix/internal/index"
-	"go.alanpearce.eu/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>
-				if config.DevMode {
-					<th scope="col">Score</th>
-				}
-			</tr>
-		</thead>
-		<tbody>
-			for _, hit := range result.Hits {
-				if m := convertMatch[nix.Package](hit.Data); m != nil {
-					@packageRow(hit, *m)
-				}
-			}
-		</tbody>
-	</table>
-}
-
-templ packageRow(hit index.DocumentMatch, p nix.Package) {
-	<tr>
-		<td>
-			@openDialogLink(p.Attribute)
-		</td>
-		<td>
-			{ p.Name }
-		</td>
-		<td>
-			{ p.Description }
-		</td>
-		if config.DevMode {
-			<td>
-				@score(hit)
-			</td>
-		}
-	</tr>
-}
diff --git a/internal/components/page.go b/internal/components/page.go
new file mode 100644
index 0000000..5cfa4ff
--- /dev/null
+++ b/internal/components/page.go
@@ -0,0 +1,152 @@
+package components
+
+import (
+	"net/url"
+
+	"go.alanpearce.eu/searchix/frontend"
+	"go.alanpearce.eu/searchix/internal/config"
+
+	g "go.alanpearce.eu/gomponents"
+	c "go.alanpearce.eu/gomponents/components"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func Page(tdata TemplateData, children ...g.Node) g.Node {
+	return Doctype(
+		HTML(
+			Lang("en-GB"),
+			Head(
+				Meta(Charset("utf-8")),
+				Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
+				TitleEl(g.Text("Searchix"), g.If(config.DevMode, g.Text(" (Dev)"))),
+				g.Map(tdata.Assets.Stylesheets, css),
+				g.Raw(tdata.ExtraHeadHTML),
+				Link(
+					Rel("search"),
+					Type("application/opensearchdescription+xml"),
+					TitleAttr("Searchix "+sourceNameAndType(nil)),
+					Href(joinPath("opensearch.xml")),
+				),
+				g.Map(tdata.Sources, func(source *config.Source) g.Node {
+					return Link(
+						Rel("search"),
+						Type("application/opensearchdescription+xml"),
+						TitleAttr("Searchix "+sourceNameAndType(source)),
+						Href(joinPath("/", source.Importer.String(), source.Key, "opensearch.xml")),
+					)
+				}),
+			),
+			Body(
+				Header(
+					Nav(
+						H1(A(Href("/"), g.Text("Searchix"))),
+						A(
+							c.Classes{
+								"current": tdata.Source == nil,
+							},
+							g.If(
+								tdata.Source == nil,
+								Href("/"),
+								Href(joinPathQuery("/", tdata.Query)),
+							),
+							g.Text("All"),
+						),
+						g.Map(tdata.Sources, func(source *config.Source) g.Node {
+							if tdata.Source != nil && tdata.Source.Name == source.Name {
+								return A(
+									Class("current"),
+									Href(
+										joinPath(
+											"/",
+											source.Importer.String(),
+											source.Key,
+											"search",
+										),
+									),
+									g.Text(source.Name),
+								)
+							}
+
+							return A(
+								Href(
+									joinPathQuery(
+										joinPath(
+											"/",
+											source.Importer.String(),
+											source.Key,
+											"search",
+										),
+										tdata.Query,
+									),
+								),
+								g.Text(source.Name),
+							)
+						}),
+					),
+				),
+				Main(children...),
+				Footer(
+					g.If(config.Version != "",
+						g.Group([]g.Node{
+							g.Text("Searchix "),
+							A(
+								Href("https://git.sr.ht/~alanpearce/searchix/refs/"+config.Version),
+								g.Text(config.Version),
+							),
+						}),
+					),
+					g.Text("Made by "),
+					A(Href("https://alanpearce.eu"), g.Text("Alan Pearce")),
+					g.Text(". "),
+					A(Href("https://git.sr.ht/~alanpearce/searchix"), g.Text("Source code")),
+					A(Href("https://todo.sr.ht/~alanpearce/searchix"), g.Text("Report issues")),
+				),
+			),
+		),
+	)
+}
+
+func css(css *frontend.Asset) g.Node {
+	return Link(Href(css.URL), Rel("stylesheet"),
+		Integrity("sha256-"+css.Base64SHA256))
+}
+
+func script(s *frontend.Asset) g.Node {
+	return Script(
+		Src(s.URL),
+		Defer(),
+		g.Attr("integrity", "sha256-"+s.Base64SHA256),
+	)
+}
+
+func sourceNameAndType(source *config.Source) string {
+	if source == nil {
+		return "Combined"
+	}
+
+	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) string {
+	u, err := url.JoinPath(base, parts...)
+	if err != nil {
+		panic(err)
+	}
+
+	return u
+}
+
+func joinPathQuery(path string, query string) string {
+	if query == "" {
+		return path
+	}
+
+	return path + "?query=" + url.QueryEscape(query)
+}
diff --git a/internal/components/page.templ b/internal/components/page.templ
deleted file mode 100644
index 62b2937..0000000
--- a/internal/components/page.templ
+++ /dev/null
@@ -1,123 +0,0 @@
-package components
-
-import (
-	"context"
-	"io"
-	"net/url"
-
-	"go.alanpearce.eu/searchix/frontend"
-	"go.alanpearce.eu/searchix/internal/config"
-)
-
-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
-				if config.DevMode {
-					(Dev)
-				}
-			</title>
-			for _, sheet := range tdata.Assets.Stylesheets {
-				<link href={ sheet.URL } rel="stylesheet" integrity={ "sha256-" + sheet.Base64SHA256 }/>
-			}
-			@Unsafe(tdata.ExtraHeadHTML)
-			<link
-				rel="search"
-				type="application/opensearchdescription+xml"
-				title={ "Searchix " + sourceNameAndType(nil) }
-				href={ string(joinPath("opensearch.xml")) }
-			/>
-			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>
-					<a
-						if tdata.Source == nil {
-							class="current"
-							href="/"
-						} else {
-							href={ joinPathQuery("/", tdata.Query) }
-						}
-					>All</a>
-					for _, source := range tdata.Sources {
-						<a
-							if tdata.Source != nil && 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>
-				if config.Version != "" {
-					Searchix
-					<a href={ templ.SafeURL("https://git.sr.ht/~alanpearce/searchix/refs/" + config.Version) }>
-						{ config.Version }
-					</a>
-				}
-				Made by <a href="https://alanpearce.eu">Alan Pearce</a>.
-				<a href="https://git.sr.ht/~alanpearce/searchix">Source code</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 {
-	if source == nil {
-		return "Combined"
-	}
-
-	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.go b/internal/components/results.go
new file mode 100644
index 0000000..4f07a78
--- /dev/null
+++ b/internal/components/results.go
@@ -0,0 +1,65 @@
+package components
+
+import (
+	"go.alanpearce.eu/searchix/internal/config"
+
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func Results(r ResultData) g.Node {
+	if r.Query == "" {
+		return Br()
+	}
+
+	if r.Results == nil || r.Results.Total == 0 {
+		return Span(Role("status"), g.Text("Nothing found"))
+	}
+
+	var content g.Node
+	if r.Source != nil {
+		switch r.Source.Importer {
+		case config.Options:
+			content = Options(r.Results)
+		case config.Packages:
+			content = Packages(r.Results)
+		}
+	} else {
+		content = Combined(r.Results)
+	}
+
+	return g.Group([]g.Node{
+		content,
+		Footer(
+			g.Attr("aria-label", "pagination"),
+			Nav(
+				ID("pagination"),
+				g.If(r.Prev != "",
+					A(Class("button"), Href(r.Prev), Rel("prev"), g.Text("Prev")),
+				),
+				g.If(r.Next != "",
+					A(Class("button"), Href(r.Next), Rel("next"), g.Text("Next")),
+				),
+			),
+			Span(
+				Role("status"),
+				g.Textf("%d results", r.Results.Total),
+			),
+			g.If(r.Next != r.Prev && r.Results.Total < config.MaxResultsShowAll,
+				A(Href(r.All), g.Text("Show All")),
+			),
+		),
+	})
+}
+
+func ResultsPage(r ResultData) g.Node {
+	return SearchPage(r.TemplateData, r, Results(r))
+}
+
+func openDialogLink(attr string) g.Node {
+	return A(Class("open-dialog"), Href(attr), g.Text(attr))
+}
+
+func openCombinedDialogLink(attr string) g.Node {
+	return A(Class("open-dialog"), Href("/"+attr), g.Text(attr))
+}
diff --git a/internal/components/results.templ b/internal/components/results.templ
deleted file mode 100644
index fee211c..0000000
--- a/internal/components/results.templ
+++ /dev/null
@@ -1,64 +0,0 @@
-package components
-
-import (
-	"go.alanpearce.eu/searchix/internal/config"
-	"go.alanpearce.eu/searchix/internal/nix"
-	"strconv"
-)
-
-func convertMatch[I nix.Importable](m nix.Importable) *I {
-	i, ok := m.(I)
-	if !ok {
-		return nil
-	}
-	return &i
-}
-
-templ Results(r ResultData) {
-	if r.Query != "" {
-		if r.Results != nil && r.Results.Total > 0 {
-			if r.Source != nil {
-				switch r.Source.Importer {
-					case config.Options:
-						@Options(r.Results)
-					case config.Packages:
-						@Packages(r.Results)
-				}
-			} else {
-				@Combined(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>
-				if r.Next != r.Prev && r.Results.Total < config.MaxResultsShowAll {
-					<a href={ templ.SafeURL(r.All) }>Show All</a>
-				}
-			</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>
-}
-
-templ openCombinedDialogLink(attr string) {
-	<a class="open-dialog" href={ templ.SafeURL("/" + attr) }>{ attr }</a>
-}
diff --git a/internal/components/search.go b/internal/components/search.go
new file mode 100644
index 0000000..0a7c991
--- /dev/null
+++ b/internal/components/search.go
@@ -0,0 +1,63 @@
+package components
+
+import (
+	g "go.alanpearce.eu/gomponents"
+	. "go.alanpearce.eu/gomponents/html"
+)
+
+func Search(tdata TemplateData, r ResultData) g.Node {
+	return Form(
+		ID("search"),
+		Role("search"),
+		FieldSet(
+			Legend(
+				ID("legend"),
+				H2(g.Textf("%s search", sourceNameAndType(tdata.Source))),
+			),
+			Input(
+				ID("query"),
+				Aria("labelledby", "legend"),
+				Name("query"),
+				Type("search"),
+				Value(r.Query),
+				AutoFocus(),
+				g.Attr("spellcheck", "false"),
+				g.Attr("autocapitalize", "none"),
+			),
+			Button(g.Text("Search")),
+		),
+	)
+}
+
+func SearchPage(tdata TemplateData, r ResultData, children ...g.Node) g.Node {
+	return Page(
+		tdata,
+		P(
+			g.Text("Search Nix packages and options from "),
+			A(Href("https://nixos.org"), g.Text("NixOS")),
+			g.Text(", "),
+			A(Href("https://github.com/LnL7/nix-darwin"), g.Text("nix-darwin")),
+			g.Text(" and "),
+			A(Href("https://github.com/nix-community/home-manager"), g.Text("home-manager")),
+		),
+		script(tdata.Assets.ByPath["/static/search.js"]),
+		Search(tdata, r),
+		Section(
+			ID("results"),
+			Role("list"),
+			Aria("label", "search results"),
+			g.Group(children),
+		),
+		Dialog(
+			ID("dialog"),
+			Button(AutoFocus(), g.Text("Close")),
+		),
+		NoScript(
+			P(
+				Class("notice"),
+				g.Text("Everything should work fine without JavaScript. If that is not the case, "),
+				A(Href("https://todo.sr.ht/~alanpearce/searchix"), g.Text("report an issue")),
+			),
+		),
+	)
+}
diff --git a/internal/components/search.templ b/internal/components/search.templ
deleted file mode 100644
index 7076772..0000000
--- a/internal/components/search.templ
+++ /dev/null
@@ -1,44 +0,0 @@
-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"
-				autocapitalize="none"
-			/>
-			<button>Search</button>
-		</fieldset>
-	</form>
-}
-
-templ SearchPage(tdata TemplateData, r ResultData) {
-	@Page(tdata) {
-		<p>
-			Search Nix packages and options from <a href="https://nixos.org">NixOS</a>, <a href="https://github.com/LnL7/nix-darwin">nix-darwin</a>
-			and <a href="https://github.com/nix-community/home-manager">home-manager</a>
-		</p>
-		@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>
-		<noscript>
-			<p class="notice">
-				Everything should work fine without JavaScript. If that is not the case, <a href="https://todo.sr.ht/~alanpearce/searchix">report an issue</a>
-			</p>
-		</noscript>
-	}
-}
diff --git a/internal/nix/option.go b/internal/nix/option.go
index c1cc4c3..96c4546 100644
--- a/internal/nix/option.go
+++ b/internal/nix/option.go
@@ -1,5 +1,13 @@
 package nix
 
+import (
+	"io"
+
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/extension"
+	"gitlab.com/tozd/go/errors"
+)
+
 type Markdown string
 
 type Value struct {
@@ -35,3 +43,12 @@ func (p Option) GetName() string {
 func (p Option) GetSource() string {
 	return p.Source
 }
+
+var md = goldmark.New(
+	goldmark.WithExtensions(extension.NewLinkify()),
+)
+
+// implements gomponent.Node
+func (text Markdown) Render(w io.Writer) error {
+	return errors.WithStack(md.Convert([]byte(text), w))
+}
diff --git a/internal/server/error.go b/internal/server/error.go
index c2acf48..b51cfa9 100644
--- a/internal/server/error.go
+++ b/internal/server/error.go
@@ -29,9 +29,9 @@ func createErrorHandler(
 		w.Header().Del("Vary")
 		w.WriteHeader(code)
 		if r.Header.Get("Fetch") == "true" {
-			err = components.Error(indexData).Render(r.Context(), w)
+			err = components.Error(indexData).Render(w)
 		} else {
-			err = components.ErrorPage(indexData).Render(r.Context(), w)
+			err = components.ErrorPage(indexData).Render(w)
 		}
 		if err != nil {
 			log.Error(
diff --git a/internal/server/mux.go b/internal/server/mux.go
index 1507860..2bbff8e 100644
--- a/internal/server/mux.go
+++ b/internal/server/mux.go
@@ -44,8 +44,6 @@ func applyDevModeOverrides(cfg *config.Config) {
 	}
 	cfg.Web.ContentSecurityPolicy.ScriptSrc = append(
 		cfg.Web.ContentSecurityPolicy.ScriptSrc,
-		cfg.Web.BaseURL.JoinPath("_templ/reload/script.js").String(),
-		"http://localhost:7331",
 		"'unsafe-inline'",
 	)
 }
@@ -174,9 +172,9 @@ func NewMux(
 				var baseErr error
 				if r.Header.Get("Fetch") == "true" {
 					w.Header().Add("Content-Type", "text/html; charset=utf-8")
-					baseErr = components.Results(tdata).Render(r.Context(), w)
+					baseErr = components.Results(tdata).Render(w)
 				} else {
-					baseErr = components.ResultsPage(tdata).Render(r.Context(), w)
+					baseErr = components.ResultsPage(tdata).Render(w)
 				}
 				if baseErr != nil {
 					log.Error("template error", "template", importerType, "error", baseErr)
@@ -192,7 +190,7 @@ func NewMux(
 						Assets:        frontend.Assets,
 					},
 					components.ResultData{},
-				).Render(r.Context(), w)
+				).Render(w)
 				if err != nil {
 					errorHandler(w, r, err.Error(), http.StatusInternalServerError)
 
@@ -248,9 +246,9 @@ func NewMux(
 			var baseErr error
 			if r.Header.Get("Fetch") == "true" {
 				w.Header().Add("Content-Type", "text/html; charset=utf-8")
-				baseErr = components.Detail(*doc).Render(r.Context(), w)
+				baseErr = components.Detail(*doc).Render(w)
 			} else {
-				baseErr = components.DetailPage(tdata, *doc).Render(r.Context(), w)
+				baseErr = components.DetailPage(tdata, *doc).Render(w)
 			}
 			if baseErr != nil {
 				log.Error("template error", "template", importerSingular, "error", baseErr)