From a1dfc548198a1326e71f1dd70303a5d3441f7a39 Mon Sep 17 00:00:00 2001
From: Alan Pearce
Date: Fri, 21 Jun 2024 13:02:08 +0200
Subject: refactor: switch to templ for HTML templates
---
internal/components/data.go | 37 ++++++++++++
internal/components/detail.templ | 20 +++++++
internal/components/error.templ | 18 ++++++
internal/components/homepage.templ | 10 ++++
internal/components/markdown.templ | 35 ++++++++++++
internal/components/optionDetail.templ | 58 +++++++++++++++++++
internal/components/options.templ | 34 +++++++++++
internal/components/packageDetail.templ | 99 +++++++++++++++++++++++++++++++++
internal/components/packages.templ | 37 ++++++++++++
internal/components/page.templ | 91 ++++++++++++++++++++++++++++++
internal/components/results.templ | 44 +++++++++++++++
internal/components/search.templ | 34 +++++++++++
internal/config/default.go | 4 ++
internal/config/structs.go | 4 +-
internal/server/error.go | 10 ++--
internal/server/mux.go | 93 +++++++++++++------------------
internal/server/templates.go | 25 +--------
17 files changed, 569 insertions(+), 84 deletions(-)
create mode 100644 internal/components/data.go
create mode 100644 internal/components/detail.templ
create mode 100644 internal/components/error.templ
create mode 100644 internal/components/homepage.templ
create mode 100644 internal/components/markdown.templ
create mode 100644 internal/components/optionDetail.templ
create mode 100644 internal/components/options.templ
create mode 100644 internal/components/packageDetail.templ
create mode 100644 internal/components/packages.templ
create mode 100644 internal/components/page.templ
create mode 100644 internal/components/results.templ
create mode 100644 internal/components/search.templ
(limited to 'internal')
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) {
+
+ { strconv.Itoa(tdata.Code) }
+ { tdata.Message }
+
+}
+
+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) {
+
+ Search Nix Packages and options from NixOS, Darwin and Home-Manager
+
+ Source code
+ }
+}
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) {
+ { option.Name }
+ @markdown(option.Description)
+
+ if option.Type != "" {
+ - Type
+ { option.Type }
+ }
+ if option.Default != nil {
+ if option.Default.Text != "" || option.Default.Markdown != "" {
+ - Default
+ -
+ if option.Default.Markdown != "" {
+ @markdown(option.Default.Markdown)
+ } else {
+
{ option.Default.Text }
+ }
+
+ }
+ }
+ if option.Example != nil {
+ if option.Example.Text != "" || option.Example.Markdown != "" {
+ - Example
+ -
+ if option.Example.Markdown != "" {
+ @markdown(option.Example.Markdown)
+ } else {
+
{ option.Example.Text }
+ }
+
+ }
+ }
+ if option.RelatedPackages != "" {
+ - Related Packages
+ -
+ @markdown(option.RelatedPackages)
+
+ }
+ if len(option.Declarations) > 0 {
+ - Declared
+ for _, d := range option.Declarations {
+ -
+ { d.Name }
+
+ }
+ }
+
+}
+
+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) {
+
+
+
+ Title |
+ Description |
+
+
+
+ for _, hit := range result.Hits {
+ @optionRow(hit.Data.(nix.Option))
+ }
+
+
+}
+
+templ optionRow(o nix.Option) {
+
+
+ @openDialogLink(o.Name)
+ |
+
+ @markdown(firstSentence(o.Description))
+
+ |
+
+}
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) {
+
+ if pkg.Broken {
+ { pkg.Attribute }
+ } else {
+ { pkg.Attribute }
+ }
+
+ if pkg.LongDescription != "" {
+ @markdown(pkg.LongDescription)
+ } else {
+ { pkg.Description }
+ }
+
+ if pkg.MainProgram != "" {
+ - Main Program
+ -
+
{ pkg.MainProgram }
+
+ }
+ if len(pkg.Homepages) > 0 {
+ - Homepage
+ -
+
+ for _, u := range pkg.Homepages {
+ -
+ { u }
+
+ }
+
+
+ }
+ if pkg.Version != "" {
+ - Version
+ - { pkg.Version }
+ }
+ if len(pkg.Licenses) > 0 {
+ - License
+ -
+
+ for _, l := range pkg.Licenses {
+ -
+ if l.URL != "" {
+ { licenseName(l) }
+ } else {
+ { licenseName(l) }
+ }
+ if l.AppendixURL != "" {
+ Appendix
+ }
+
+ }
+
+
+ }
+ if len(pkg.Maintainers) > 0 {
+ - Maintainers
+ -
+
+ for _, m := range pkg.Maintainers {
+ -
+ if m.Github != "" {
+ { m.Name }
+ } else {
+ { m.Name }
+ }
+
+ }
+
+
+ }
+ if pkg.Definition != "" {
+ - Defined
+ -
+ Source
+
+ }
+
+}
+
+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) {
+
+
+
+ Attribute |
+ Name |
+ Description |
+
+
+
+ for _, hit := range result.Hits {
+ @packageRow(hit.Data.(nix.Package))
+ }
+
+
+}
+
+templ packageRow(p nix.Package) {
+
+
+ @openDialogLink(p.Attribute)
+ |
+
+ { p.Name }
+ |
+
+ { p.Description }
+ |
+
+}
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) {
+
+
+
+
+
+ Searchix
+ for _, sheet := range tdata.Assets.Stylesheets {
+
+ }
+ @Unsafe(tdata.ExtraHeadHTML)
+ for _, source := range tdata.Sources {
+
+ }
+
+
+
+
+ { children... }
+
+
+
+
+}
+
+templ script(s *frontend.Asset) {
+
+}
+
+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)
+ }
+
+ } else {
+ Nothing found
+ }
+ } else {
+
+ }
+}
+
+templ ResultsPage(r ResultData) {
+ @SearchPage(r.TemplateData, r) {
+ @Results(r)
+ }
+}
+
+templ openDialogLink(attr string) {
+ { attr }
+}
diff --git a/internal/components/search.templ b/internal/components/search.templ
new file mode 100644
index 0000000..2cae754
--- /dev/null
+++ b/internal/components/search.templ
@@ -0,0 +1,34 @@
+package components
+
+templ Search(tdata TemplateData, r ResultData) {
+
+}
+
+templ SearchPage(tdata TemplateData, r ResultData) {
+ @Page(tdata) {
+ @script(tdata.Assets.ByPath["/static/search.js"])
+ @Search(tdata, r)
+
+
+ }
+}
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 . Can be used to override styling, add scripts, etc."`
+ ExtraHeadHTML string `comment:"Content to add to HTML . 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
}
--
cgit 1.4.1