about summary refs log tree commit diff stats
path: root/internal/components
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/components
parentcac323d9ae70f55a43fd99b73e60cf614be11797 (diff)
downloadsearchix-fc5fd2edd9b8282497e33a18300eab694d8a89c6.tar.lz
searchix-fc5fd2edd9b8282497e33a18300eab694d8a89c6.tar.zst
searchix-fc5fd2edd9b8282497e33a18300eab694d8a89c6.zip
refactor: switch to templ for HTML templates
Diffstat (limited to 'internal/components')
-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
12 files changed, 517 insertions, 0 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>
+	}
+}