all repos — searchix @ a1dfc548198a1326e71f1dd70303a5d3441f7a39

Search engine for NixOS, nix-darwin, home-manager and NUR users

refactor: switch to templ for HTML templates

Alan Pearce
commit

a1dfc548198a1326e71f1dd70303a5d3441f7a39

parent

cac323d9ae70f55a43fd99b73e60cf614be11797

M .editorconfig.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]
M .gitignore.gitignore
@@ -25,3 +25,6 @@ /.pre-commit-config.yaml
/frontend/static/base.css /data/ /config.toml + +*_templ.go +*_templ.txt
M defaults.tomldefaults.toml
@@ -71,6 +71,8 @@ [Importer.Sources]
[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 @@
[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 @@
[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 @@
[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.
M frontend/assets.gofrontend/assets.go
@@ -12,8 +12,8 @@ "github.com/pkg/errors"
) 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 @@ Base64SHA256 string
} type AssetCollection struct { - Scripts map[string]*Asset - Stylesheets map[string]*Asset + Scripts []*Asset + Stylesheets []*Asset ByPath map[string]*Asset }
@@ -61,7 +61,7 @@ asset, err := newAsset(filename)
if err != nil { return err } - Assets.Scripts[filename] = asset + Assets.Scripts = append(Assets.Scripts, asset) Assets.ByPath[asset.URL] = asset }
@@ -78,7 +78,7 @@ asset, err := newAsset(filename)
if err != nil { return err } - Assets.Stylesheets[filename] = asset + Assets.Stylesheets = append(Assets.Stylesheets, asset) Assets.ByPath[asset.URL] = asset }
M frontend/static/search.jsfrontend/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);
M frontend/static/style.cssfrontend/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; +}
D frontend/templates/blocks/error.gotmpl
@@ -1,6 +0,0 @@
-{{- define "main" }} - <p class="notice error"> - {{ .Code }} - {{ .Message }} - </p> -{{- end }}
D frontend/templates/blocks/option.gotmpl
@@ -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 }}
D frontend/templates/blocks/options.gotmpl
@@ -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 }}
D frontend/templates/blocks/package.gotmpl
@@ -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 }}
D frontend/templates/blocks/packages.gotmpl
@@ -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 }}
D frontend/templates/blocks/results.gotmpl
@@ -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 }}
D frontend/templates/blocks/search.gotmpl
@@ -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 }}
D frontend/templates/index.gotmpl
@@ -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>
M go.modgo.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 @@ github.com/blevesearch/zapx/v16 v16.1.4 // indirect
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
M go.sumgo.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=
M gomod2nix.tomlgomod2nix.toml
@@ -7,6 +7,9 @@ hash = "sha256-0LRWL5DUHW3gXQhPAhUCxnUCN7HN1qKI2yZp8MrDN6M="
[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 @@ hash = "sha256-N3+Lv9lEZjrdOWdQhFj6Y3Iap4rVLEQeI8/eFFyAMZ0="
[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="
A 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 +}
A 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) + } +}
A 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) + } +}
A 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> + } +}
A 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 + }) +}
A 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) + } +}
A 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> +}
A 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) + } +}
A 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> +}
A 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)) +}
A 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> +}
A 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> + } +}
M internal/config/default.gointernal/config/default.go
@@ -53,6 +53,7 @@ UpdateAt: mustLocalTime("04:00:00"),
Sources: map[string]*Source{ "nixos": { Name: "NixOS", + Order: 0, Key: "nixos", Enable: true, Importer: Options,
@@ -67,6 +68,7 @@ Repo: nixpkgs,
}, "darwin": { Name: "Darwin", + Order: 1, Key: "darwin", Enable: false, Importer: Options,
@@ -85,6 +87,7 @@ },
}, "home-manager": { Name: "Home Manager", + Order: 2, Key: "home-manager", Enable: false, Importer: Options,
@@ -103,6 +106,7 @@ },
}, "nixpkgs": { Name: "Nix Packages", + Order: 3, Key: "nixpkgs", Enable: true, Importer: Packages,
M internal/config/structs.gointernal/config/structs.go
@@ -4,7 +4,6 @@
// 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 @@ Port int `comment:"Port number to listen on."`
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 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'."`
M internal/server/error.gointernal/server/error.go
@@ -3,6 +3,8 @@
import ( "log/slog" "net/http" + + "searchix/internal/components" "searchix/internal/config" )
@@ -14,9 +16,9 @@ var err error
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 @@ w.Header().Set("Cache-Control", "no-store")
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(
M internal/server/mux.gointernal/server/mux.go
@@ -3,7 +3,6 @@
import ( "context" "fmt" - "html/template" "io" "log" "log/slog"
@@ -16,11 +15,10 @@ "strconv"
"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 @@ Message string
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,8 +42,16 @@ cfg.Web.ContentSecurityPolicy.ScriptSrc = cfg.Web.ContentSecurityPolicy.DefaultSrc
} 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(
@@ -93,19 +73,20 @@ templates, err = loadTemplates()
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 @@ slog.Error("search error", "error", err)
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 @@ w.Header().Add("Cache-Control", "max-age=300")
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 @@ return
} 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 @@
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 @@ })
if liveReload { applyDevModeOverrides(cfg) - cfg.Web.ExtraHeadHTML = jsSnippet + cfg.Web.ExtraHeadHTML = livereload.JsSnippet liveReload := livereload.New() liveReload.Start() top.Handle("/livereload", liveReload)
M internal/server/templates.gointernal/server/templates.go
@@ -95,39 +95,16 @@ 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 } - name, _ := strings.CutSuffix(path.Base(fullname), ".gotmpl") - 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 }
M modd.confmodd.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" }
M nix/dev-shell.nixnix/dev-shell.nix
@@ -11,6 +11,7 @@ mkShell {
packages = with pkgs; [ goEnv + templ modd brotli bleve
M nix/package.nixnix/package.nix
@@ -37,6 +37,7 @@
patchPhase = '' rm -f frontend/static/base.css cp ${css} frontend/static/base.css + ${pkgs.templ}/bin/templ generate ''; tags = [ "embed" ]; ldflags = [