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) { + + + + + + + + + for _, hit := range result.Hits { + @optionRow(hit.Data.(nix.Option)) + } + +
TitleDescription
+} + +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
+
+ +
+ } + if pkg.Version != "" { +
Version
+
{ pkg.Version }
+ } + if len(pkg.Licenses) > 0 { +
License
+
+ +
+ } + if len(pkg.Maintainers) > 0 { +
Maintainers
+
+ +
+ } + 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) { + + + + + + + + + + for _, hit := range result.Hits { + @packageRow(hit.Data.(nix.Package)) + } + +
AttributeNameDescription
+} + +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) +
+ { children... } +
+ + + + } +} 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