diff options
author | Alan Pearce | 2025-03-18 22:40:46 +0100 |
---|---|---|
committer | Alan Pearce | 2025-03-19 17:33:58 +0100 |
commit | 896d844cac976afd0ee8aa73dd2fb28e15e7ac79 (patch) | |
tree | cc8d288d0039cb3d2084f43cafe8d4e0aea50e8b /internal | |
parent | 1183108baa44fde88944e9207fb7763668c2b448 (diff) | |
download | searchix-896d844cac976afd0ee8aa73dd2fb28e15e7ac79.tar.lz searchix-896d844cac976afd0ee8aa73dd2fb28e15e7ac79.tar.zst searchix-896d844cac976afd0ee8aa73dd2fb28e15e7ac79.zip |
feat: Convert templ components to gomponents
Diffstat (limited to 'internal')
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) |