diff --git a/go.mod b/go.mod index 13d69e6..8effb4f 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,10 @@ require ( github.com/sethvargo/go-envconfig v1.3.0 github.com/studio-b12/gowebdav v0.12.0 github.com/valkey-io/valkey-go v1.0.72 + github.com/valkey-io/valkey-go/mock v1.0.72 + go.uber.org/mock v0.6.0 golang.org/x/image v0.36.0 + golang.org/x/sync v0.16.0 ) require ( @@ -25,9 +28,8 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect - golang.org/x/mod v0.26.0 // indirect + golang.org/x/mod v0.27.0 // indirect golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/tools v0.35.0 // indirect + golang.org/x/tools v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 1eba967..ad4cfde 100644 --- a/go.sum +++ b/go.sum @@ -39,14 +39,18 @@ github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOV github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= github.com/valkey-io/valkey-go v1.0.72 h1:iRWt1hJyOchcEgbHSkRY3aKkcBudxvMaVMsmxuYxuxE= github.com/valkey-io/valkey-go v1.0.72/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k= +github.com/valkey-io/valkey-go/mock v1.0.72 h1:rE8K/sjlX0SRldI70Rt4/MCrYl224XD4A4vkYegP1Iw= +github.com/valkey-io/valkey-go/mock v1.0.72/go.mod h1:A4B8L3Wg85yAOl/GwNgkO/6aeGNXydwBl+86e20NQQY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= @@ -57,7 +61,7 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/components/images/controller.go b/internal/components/images/controller.go index e93c6b9..f20f63f 100644 --- a/internal/components/images/controller.go +++ b/internal/components/images/controller.go @@ -30,6 +30,7 @@ func (s *Service) getMime(path string) (mimeType string, err error) { if mimer, ok := info.(interface{ ContentType() string }); ok { mimeType = mimer.ContentType() + return } return diff --git a/internal/components/images/controller_test.go b/internal/components/images/controller_test.go new file mode 100644 index 0000000..43d8795 --- /dev/null +++ b/internal/components/images/controller_test.go @@ -0,0 +1,37 @@ +package images_test + +import ( + "context" + "errors" + "testing" + + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/test/testdata" + "github.com/valkey-io/valkey-go" + "github.com/valkey-io/valkey-go/mock" + "go.uber.org/mock/gomock" +) + +func Setup(ctx context.Context, ctrl *gomock.Controller) (*images.Service, *mock.Client) { + valkey := mock.NewClient(ctrl) + return images.New(testdata.FS(), &config.ImageConfig{Concurency: 1, Quality: 80}, valkey), valkey +} + +func BenchmarkService_GetImage(b *testing.B) { + srv, valkeyClient := Setup(b.Context(), gomock.NewController(b)) + uid, err := images.UIDFromPath("cover.jpg") + if err != nil { + b.Error("could not get uid", err) + } + + b.ReportAllocs() + for b.Loop() { + valkeyClient.EXPECT().Do(gomock.Any(), gomock.Any()).Return(mock.ErrorResult(errors.New("adf"))) + valkeyClient.EXPECT().Do(gomock.Any(), gomock.Any()).Return(mock.Result(valkey.ValkeyMessage{})) + _, _, err = srv.GetImage(b.Context(), uid, images.Options{Width: 500, Quality: 80}) + if err != nil { + b.Error(err) + } + } +} diff --git a/internal/components/images/helpers.go b/internal/components/images/helpers.go index cc29962..5ee781d 100644 --- a/internal/components/images/helpers.go +++ b/internal/components/images/helpers.go @@ -9,6 +9,11 @@ import ( "strings" ) +const ( + sourceSetSteps = 300 + sourceSetMax = 1500 +) + func UIDFromPath(path string) (uid string, err error) { var b bytes.Buffer // NewWriter with NoDict (nil) creates a raw DEFLATE compressor @@ -57,14 +62,11 @@ func PathFromUID(uid string) (string, error) { func SourceSet(src string) string { sb := strings.Builder{} - const steps = 300 - const max = 1500 - i := 1 for { - width := i * steps + width := i * sourceSetSteps sb.WriteString(fmt.Sprintf("%s?w=%d %dw, ", src, width, width)) - if width >= max { + if width >= sourceSetMax { return sb.String() } sb.WriteRune(',') diff --git a/internal/components/page/controller.go b/internal/components/page/controller.go index 188f986..d567d3b 100644 --- a/internal/components/page/controller.go +++ b/internal/components/page/controller.go @@ -16,6 +16,7 @@ type PageHeader struct { type Page struct { PageHeader Content string + Images []Image } func (s *Service) GetPages(ctx context.Context) (pages []PageHeader, err error) { @@ -46,7 +47,7 @@ func (s *Service) GetPage(ctx context.Context, uid string) (page Page, err error return } - html, err := s.getHTML(ctx, p) + html, imgs, err := s.getHTML(ctx, p) if err != nil { return } @@ -54,6 +55,7 @@ func (s *Service) GetPage(ctx context.Context, uid string) (page Page, err error page = Page{ PageHeader: p, Content: string(html), + Images: imgs, } return } diff --git a/internal/components/page/html.go b/internal/components/page/html.go index fa2820e..d4de503 100644 --- a/internal/components/page/html.go +++ b/internal/components/page/html.go @@ -14,7 +14,13 @@ import ( "github.com/gomarkdown/markdown/parser" ) -func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, err error) { +// image can be used to preload +type Image struct { + SrcSet string + Src string +} + +func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, imgs []Image, err error) { extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock p := parser.NewWithExtensions(extensions) @@ -24,7 +30,7 @@ func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, err htmlFlags := html.CommonFlags | html.HrefTargetBlank opts := html.RendererOptions{ Flags: htmlFlags, - RenderNodeHook: imageMiddleware(page), + RenderNodeHook: imageMiddleware(page, &imgs), } renderer := html.NewRenderer(opts) @@ -32,7 +38,7 @@ func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, err return } -func imageMiddleware(page PageHeader) func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { +func imageMiddleware(page PageHeader, imgs *[]Image) func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { if !entering { return ast.GoToNext, false @@ -53,14 +59,20 @@ func imageMiddleware(page PageHeader) func(w io.Writer, node ast.Node, entering slog.Error("could not get image url", "err", err) return ast.GoToNext, false } + image := Image{ + SrcSet: images.SourceSet(src), + Src: src + "?w=1024", + } img.Attribute = &ast.Attribute{Attrs: map[string][]byte{ - "srcset": []byte(images.SourceSet(src)), + // "srcset": []byte(image.SrcSet), "fetchpriority": []byte("low"), "loading": []byte("lazy"), "class": []byte("min-h-48"), - "src": []byte(src), + "src": []byte(image.Src), }} + + *imgs = append(*imgs, image) // // img.Attrs["srcset"] = } diff --git a/internal/handlers/resthome/controller.go b/internal/handlers/resthome/controller.go index a911787..7e781e9 100644 --- a/internal/handlers/resthome/controller.go +++ b/internal/handlers/resthome/controller.go @@ -1,8 +1,10 @@ package resthome import ( + "fmt" "net/http" + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages" ) @@ -17,6 +19,7 @@ func (h *Handler) home(w http.ResponseWriter, r *http.Request) { } func (h *Handler) page(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() uid := r.PathValue("uid") page, err := h.src.GetPage(r.Context(), uid) @@ -25,5 +28,14 @@ func (h *Handler) page(w http.ResponseWriter, r *http.Request) { return } - h.r.Render(r.Context(), w, r, pages.ContentPage(page)) + for i, image := range page.Images { + if i > 3 { + break + } + ctx = templer.AppendHeader(ctx, fmt.Sprintf(``, image.Src)) + } + + ctx = templer.PageTitle(ctx, page.Title) + + h.r.Render(ctx, w, r, pages.ContentPage(page)) } diff --git a/internal/pkg/templer/header.go b/internal/pkg/templer/header.go new file mode 100644 index 0000000..3a1a348 --- /dev/null +++ b/internal/pkg/templer/header.go @@ -0,0 +1,48 @@ +package templer + +import ( + "context" +) + +// GetHeader returns a slice of strings that should be added to the html head. +// Because strings are immutable a slice of strings is used. +func GetHeader(ctx context.Context) []string { + v := ctx.Value(headerKey) + if v == nil { + return []string{} + } + + header, ok := v.([]string) + if !ok { + return []string{} + } + return header +} + +// AppendHeader lets you add lines to the header. +func AppendHeader(ctx context.Context, v ...string) context.Context { + header := GetHeader(ctx) + + header = append(header, v...) + + return context.WithValue(ctx, headerKey, header) +} + +// AppendHeader lets you add lines to the header. +func PageTitle(ctx context.Context, title string) context.Context { + return context.WithValue(ctx, titleKey, title) +} + +// AppendHeader lets you add lines to the header. +func GetTitle(ctx context.Context, main string) string { + v := ctx.Value(titleKey) + if v == nil { + return main + } + + title, ok := v.(string) + if !ok { + return main + } + return title + " | " + main +} diff --git a/internal/pkg/templer/middleware.go b/internal/pkg/templer/middleware.go index 22d429f..9de2f54 100644 --- a/internal/pkg/templer/middleware.go +++ b/internal/pkg/templer/middleware.go @@ -11,6 +11,8 @@ type ctxKey int const ( pathKey ctxKey = iota + headerKey + titleKey ) func Middleware(next http.Handler) http.HandlerFunc { diff --git a/internal/server/server.go b/internal/server/server.go index 479c3ca..4e814af 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,3 +1,4 @@ +// Package server provides the http server for schreifuchs.ch. package server import ( @@ -17,6 +18,7 @@ type Server struct { cfg config.Cfg } +// Start starts a new http server. When the context gets canceled, the server stops gracefully. func Start(ctx context.Context, cfg config.Cfg) (err error) { port, _ := strconv.Atoi(os.Getenv("PORT")) if port == 0 { diff --git a/package.json b/package.json index 18d8661..948e806 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "build:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --minify", "watch:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --watch", - "build:js": "cp node_modules/htmx.org/dist/htmx.min.js web/static/js/htmx.min.js && cp ./node_modules/htmx-ext-preload/dist/preload.min.js ./web/static/js/preload.min.js" + "build:js": "cp node_modules/htmx.org/dist/htmx.min.js web/static/js/htmx.min.js && cp ./node_modules/htmx-ext-preload/dist/preload.min.js ./web/static/js/preload.min.js && cp node_modules/htmx-ext-head-support/dist/head-support.min.js web/static/js/head-support.min.js" }, "keywords": [], "author": "", @@ -20,6 +20,7 @@ "tailwindcss": "^4.1.18" }, "dependencies": { + "htmx-ext-head-support": "^2.0.5", "htmx-ext-preload": "^2.1.2", "htmx.org": "^2.0.8" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b64bdd..dffdfde 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + htmx-ext-head-support: + specifier: ^2.0.5 + version: 2.0.5 htmx-ext-preload: specifier: ^2.1.2 version: 2.1.2 @@ -280,6 +283,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + htmx-ext-head-support@2.0.5: + resolution: {integrity: sha512-fTzqnCw7OnMijiS48CCBFBHTuOMvycbX0nEFRL0lkRxyu5DFWX90IV1SwXpQ92fJf02IzkXcRNyjXn3j0QtYxA==} + htmx-ext-preload@2.1.2: resolution: {integrity: sha512-foD06XAITUUQApCUQnPexyJ6B7+4vFGMQ+t42unTVTGRu/CYHdAAt6oHg7eekPHT7NRnbM5hjnGVKtClryPabg==} @@ -623,6 +629,10 @@ snapshots: graceful-fs@4.2.11: {} + htmx-ext-head-support@2.0.5: + dependencies: + htmx.org: 2.0.8 + htmx-ext-preload@2.1.2: dependencies: htmx.org: 2.0.8 diff --git a/test/testdata/cover.jpg b/test/testdata/cover.jpg new file mode 100644 index 0000000..28cd72a Binary files /dev/null and b/test/testdata/cover.jpg differ diff --git a/test/testdata/resource.go b/test/testdata/resource.go new file mode 100644 index 0000000..e812caa --- /dev/null +++ b/test/testdata/resource.go @@ -0,0 +1,62 @@ +package testdata + +import ( + "bytes" + "embed" + "io" + "os" + "strings" +) + +//go:embed * +var fs embed.FS + +func FS() mockFS { + return mockFS{ + FS: fs, + } +} + +type mockFS struct { + embed.FS +} + +type mockMimer struct{ os.FileInfo } + +func (m *mockMimer) ContentType() string { + parts := strings.Split(m.Name(), ".") + + suffix := parts[len(parts)-1] + + switch suffix { + case "jpg": + return "image/jpeg" + default: + return "" + } +} + +func (m mockFS) Read(path string) (b []byte, err error) { + file, err := m.Open(path) + if err != nil { + return + } + defer file.Close() + + buff := bytes.NewBuffer(make([]byte, 0, 1024)) + + _, err = io.Copy(buff, file) + + return buff.Bytes(), err +} + +func (m mockFS) Stat(path string) (info os.FileInfo, err error) { + file, err := m.Open(path) + if err != nil { + return + } + + info, err = file.Stat() + info = &mockMimer{info} + return +} diff --git a/web/layouts/base.templ b/web/layouts/base.templ index 067e86b..1e7c637 100644 --- a/web/layouts/base.templ +++ b/web/layouts/base.templ @@ -1,24 +1,35 @@ package layouts -import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate" -import "strings" -import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer" -import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" +import ( + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate" + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" + "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer" + "strings" +) + +templ Head(cfg config.Cfg, title string) { +
+ + +