Compare commits

..
Author SHA1 Message Date
schreifuchs aa7949d610 fix(layout): defer non critical resources 2026-09-30 19:23:17 +02:00
schreifuchs 83a239f580 fix(tracking): enable performance
release.yaml / publish (push) Successful in 4m8s
2026-09-30 15:33:17 +02:00
schreifuchs cebe3a812d feat(tracking): umami recorder support
release.yaml / publish (push) Successful in 4m13s
2026-09-30 10:55:04 +02:00
schreifuchs 2a96034fca chore(htmx): upgrade htmx to v4
release.yaml / publish (push) Successful in 3m48s
2026-09-29 11:03:38 +02:00
schreifuchs 812891a205 fix(gallery): use component template
release.yaml / publish (push) Successful in 3m51s
2026-09-29 10:46:45 +02:00
schreifuchs 297efaa4a6 fix(translations): stay on page
release.yaml / publish (push) Successful in 4m0s
2026-09-28 09:52:22 +02:00
schreifuchs c08dd355c0 fix(gallery): faster image loading
release.yaml / publish (push) Successful in 3m56s
2026-09-26 23:35:15 +02:00
schreifuchs 9ec2585030 feat(gallery): image detail view
release.yaml / publish (push) Successful in 4m0s
2026-09-26 19:28:00 +02:00
schreifuchs 72bc974aa6 feat(gallery): preload images
release.yaml / publish (push) Successful in 3m4s
2026-09-10 14:02:05 +02:00
schreifuchs c678c7aa45 fix(gallery): use correct aspect ratios
/ publish (push) Successful in 3m48s
2026-06-08 11:18:44 +02:00
schreifuchs 763163c24e feat: image gallery (#2)
/ publish (push) Successful in 4m4s
Reviewed-on: #2
2026-06-01 14:07:50 +02:00
schreifuchs ee3eba3cd9 feat(images): cache lifetime in config
/ publish (push) Successful in 3m43s
2026-03-19 22:10:20 +01:00
schreifuchs b384c70c39 feat: preload images 2026-03-19 22:07:02 +01:00
schreifuchs 1c67eb0cff chore: update readme 2026-03-19 18:30:42 +01:00
schreifuchs 16e5107185 feat: title on pages 2026-03-19 18:27:23 +01:00
schreifuchs 2b07701762 feat: better caching headers for images 2026-03-09 13:35:23 +01:00
schreifuchs a27b4a66e6 chore: use json logger 2026-03-09 09:23:31 +01:00
schreifuchs bc900e1450 chore: use better scaling kernel 2026-03-09 08:41:52 +01:00
schreifuchs 1310b7b243 feat: add umami
/ publish (push) Failing after 2m14s
2026-03-08 16:11:57 +01:00
schreifuchs 01b24f830f chore: better resource loading
/ publish (push) Successful in 9m23s
2026-03-08 01:05:57 +01:00
schreifuchs eab7eb3879 chore: better config
/ publish (push) Successful in 3m1s
2026-03-07 20:55:04 +01:00
schreifuchs fca1030e1a feat: limit image request
/ publish (push) Successful in 3m9s
2026-03-07 20:26:53 +01:00
schreifuchs 2f7ff28c9a feat: better loging
/ publish (push) Successful in 3m7s
2026-03-07 19:31:19 +01:00
schreifuchs 82e601fd7f chore: enable webp support
/ publish (push) Successful in 3m22s
2026-03-07 18:23:18 +01:00
schreifuchs f5bcc7c381 feat: configuration
/ publish (push) Successful in 3m27s
2026-03-07 16:58:19 +01:00
71 changed files with 2030 additions and 267 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
root = "." root = "."
testdata_dir = "testdata" testdata_dir = "testdata"
tmp_dir = "tmp" tmp_dir = "tmp"
env_files = [".env"]
[build] [build]
args_bin = [] args_bin = []
+8
View File
@@ -27,3 +27,11 @@ jobs:
file: ./Dockerfile file: ./Dockerfile
push: true push: true
tags: "git.schreifuchs.ch/schreifuchs/schreifuchs.ch:${{ github.ref_name }},git.schreifuchs.ch/schreifuchs/schreifuchs.ch:latest" tags: "git.schreifuchs.ch/schreifuchs/schreifuchs.ch:${{ github.ref_name }},git.schreifuchs.ch/schreifuchs/schreifuchs.ch:latest"
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
state: "published"
title: "Release ${{ github.ref_name }}"
notes: "Automated release for tag ${{ github.ref_name }}"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -7
View File
@@ -17,9 +17,6 @@ RUN pnpm run build:css && pnpm run build:js
FROM golang:1.26-alpine AS go-builder FROM golang:1.26-alpine AS go-builder
WORKDIR /app WORKDIR /app
# Install git for potential private modules (though not strictly needed here)
RUN apk add --no-cache git
# Download Go modules # Download Go modules
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
@@ -29,7 +26,7 @@ COPY . .
# Copy built frontend assets from the previous stage # Copy built frontend assets from the previous stage
COPY --from=frontend-builder /app/web/static/css/output.css ./web/static/css/ COPY --from=frontend-builder /app/web/static/css/output.css ./web/static/css/
COPY --from=frontend-builder /app/web/static/js/htmx.min.js ./web/static/js/ COPY --from=frontend-builder /app/web/static/js/ ./web/static/js/
# Generate templ files using the tool defined in go.mod # Generate templ files using the tool defined in go.mod
RUN go tool templ generate RUN go tool templ generate
@@ -41,9 +38,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/schr
FROM alpine:latest FROM alpine:latest
WORKDIR /app WORKDIR /app
# Install root certificates and timezone data
RUN apk --no-cache add ca-certificates tzdata
# Copy the compiled binary from the builder stage # Copy the compiled binary from the builder stage
COPY --from=go-builder /app/server . COPY --from=go-builder /app/server .
+7
View File
@@ -0,0 +1,7 @@
.PHONY: run
run: docker
air
.PHONY: docker
docker:
docker compose up -d
+36 -19
View File
@@ -2,35 +2,40 @@
Personal portfolio website for Schreifuchs, featuring photography, informatics, music, and video. Personal portfolio website for Schreifuchs, featuring photography, informatics, music, and video.
The site is built with **Svelte 5** and **SvelteKit**, and it is powered by a dynamic **Nextcloud** backend via WebDAV. The site is built with **Go**, **templ**, and **HTMX**, and it is powered by a dynamic **Nextcloud** backend via WebDAV.
## Features ## Features
- **Dynamic Content:** Pages are automatically discovered based on the folder structure in Nextcloud. - **Dynamic Content:** Pages are automatically discovered based on the folder structure in Nextcloud.
- **Hidden Sites:** Folders starting with `_` are excluded from the navigation menu but remain accessible via direct links. - **Hidden Sites:** Folders starting with `_` are excluded from the navigation menu but remain accessible via direct links.
- **Markdown Driven:** Content for each page is fetched from a corresponding Markdown file. - **Markdown Driven:** Content for each page is fetched from a corresponding Markdown file and rendered via `gomarkdown`.
- **Image Optimization:** Automatic image resizing via Nextcloud's Preview API for optimized delivery and `srcset` support. - **Image Optimization:** Automatic image resizing via Nextcloud's Preview API for optimized delivery and `srcset` support.
- **Asset Proxying:** Images and attachments are securely streamed from Nextcloud through a server-side proxy. - **Asset Proxying:** Images and attachments are securely streamed from Nextcloud through a server-side proxy.
- **Performance:** In-memory caching for Nextcloud requests (15-minute TTL). - **Performance:** Multi-layer caching using **Valkey** (Redis-compatible) for WebDAV requests and image scaling.
- **Responsive Design:** Styled with Tailwind CSS, optimized for all screen sizes. - **Responsive Design:** Styled with **Tailwind CSS**, optimized for all screen sizes.
## Tech Stack ## Tech Stack
- **Framework:** Svelte 5 (Runes) & SvelteKit - **Backend:** Go (Golang)
- **UI Components:** [templ](https://templ.guide/)
- **Frontend Interactivity:** [HTMX](https://htmx.org/)
- **Styling:** Tailwind CSS - **Styling:** Tailwind CSS
- **Backend:** Node.js (via `@sveltejs/adapter-node`) - **Caching:** Valkey
- **Integration:** `webdav` library for Nextcloud communication - **Integration:** `gowebdav` for Nextcloud communication
- **Content:** `@ts-stack/markdown` for rendering - **Content:** `github.com/gomarkdown/markdown` for rendering
## Configuration ## Configuration
The application requires the following environment variables in a `.env` file: The application requires the following environment variables in a `.env` file:
```env ```env
NEXTCLOUD_URL=https://your-nextcloud-instance.com FILESYSTEM_URL=https://your-nextcloud-instance.com/remote.php/dav/files/user/
NEXTCLOUD_USER=your_username FILESYSTEM_USERNAME=your_username
NEXTCLOUD_PASSWORD=your_app_password FILESYSTEM_PASSWORD=your_app_password
NEXTCLOUD_BASE_DIR=WebsiteContent # Optional: Root folder for site content CACHE_ADDRESS=localhost:6379
CACHE_PASSWORD=your_valkey_password
PORT=8080
LOG_LEVEL=DEBUG
``` ```
## Content Management ## Content Management
@@ -45,28 +50,40 @@ To add or update content, manage files in your configured Nextcloud directory:
## Development ## Development
Install dependencies: Install dependencies (Go, pnpm, and the `templ` binary):
```bash ```bash
# Install Go dependencies
go mod tidy
# Install JS/CSS dependencies
pnpm install pnpm install
``` ```
Start the development server: Start the development server with hot reloading:
```bash ```bash
pnpm run dev air
```
For CSS changes, run in a separate terminal:
```bash
pnpm run watch:css
``` ```
## Building ## Building
Build for production (Node.js): Build the project for production:
```bash ```bash
pnpm run build # Generate Go code from Templ files
go generate ./...
# Build CSS
pnpm run build:css
# Build Go binary
go build -o ./tmp/main ./cmd/schreifuchs-ch/main.go
``` ```
The output will be in the `build/` directory, ready to be served by Node.
## Image Processing ## Image Processing
To optimize images before uploading to Nextcloud: To optimize images before uploading to Nextcloud:
+43 -6
View File
@@ -1,22 +1,59 @@
package main package main
import ( import (
"context"
"log/slog" "log/slog"
"os" "os"
"os/signal"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/server" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/server"
) )
func main() { func main() {
server := server.NewServer() ctx := context.Background()
ctx, cancle := context.WithCancel(ctx)
defer cancle()
slog.SetLogLoggerLevel(slog.LevelDebug) go func() {
slog.Info("Server started", "addr", server.Addr) sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt)
<-sigint
err := server.ListenAndServe() cancle()
}()
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})))
cfg, err := config.Read(ctx)
if err != nil { if err != nil {
slog.Error("Server failed to start", "error", err) slog.Error("could not read configuaration", "err", err)
os.Exit(1) }
setupLogger(&cfg)
err = server.Start(ctx, cfg)
if err != nil {
slog.Error("error while serving", "err", err)
return
} }
} }
func setupLogger(cfg *config.Cfg) {
opts := slog.HandlerOptions{
Level: cfg.LogLevel,
}
var handler slog.Handler
if cfg.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &opts)
} else {
handler = slog.NewTextHandler(os.Stdout, &opts)
}
slog.SetDefault(
slog.New(
handler,
),
)
}
-1
View File
@@ -1,7 +1,6 @@
services: services:
valkey: valkey:
image: valkey/valkey:8 image: valkey/valkey:8
restart: always
ports: ports:
- "6379:6379" - "6379:6379"
command: valkey-server --requirepass "valkey" command: valkey-server --requirepass "valkey"
+7 -3
View File
@@ -5,12 +5,17 @@ go 1.26
tool github.com/a-h/templ/cmd/templ tool github.com/a-h/templ/cmd/templ
require ( require (
github.com/InfinityTools/go-binpack2d v1.0.0
github.com/a-h/templ v0.3.977 github.com/a-h/templ v0.3.977
github.com/alicebob/miniredis/v2 v2.36.1 github.com/alicebob/miniredis/v2 v2.36.1
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/sethvargo/go-envconfig v1.3.0
github.com/studio-b12/gowebdav v0.12.0 github.com/studio-b12/gowebdav v0.12.0
github.com/valkey-io/valkey-go v1.0.72 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/image v0.36.0
golang.org/x/sync v0.16.0
) )
require ( require (
@@ -24,9 +29,8 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/natefinch/atomic v1.0.1 // indirect github.com/natefinch/atomic v1.0.1 // indirect
github.com/yuin/gopher-lua v1.1.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/net v0.48.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.39.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
) )
+12 -4
View File
@@ -1,3 +1,5 @@
github.com/InfinityTools/go-binpack2d v1.0.0 h1:l15wC3zDlR0FoffIcPze0qbszshmGCpMncDiAxpWnE8=
github.com/InfinityTools/go-binpack2d v1.0.0/go.mod h1:3hwEmVO6ffChGwW55BVr9k4rzSYGow0OH2B8bjEdO6o=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
@@ -31,20 +33,26 @@ github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U=
github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM= github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE= 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 h1:iRWt1hJyOchcEgbHSkRY3aKkcBudxvMaVMsmxuYxuxE=
github.com/valkey-io/valkey-go v1.0.72/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k= 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 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= 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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= 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.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= 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 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
@@ -55,7 +63,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/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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+125
View File
@@ -0,0 +1,125 @@
package gallery
import (
"context"
"log/slog"
"math"
"github.com/InfinityTools/go-binpack2d"
)
type Gallery struct {
GridWith int
GridHeight int
Images []Image
}
type Set struct {
SM Gallery
LG Gallery
XLG Gallery
}
func (s *Service) GetGallerySet(ctx context.Context) (gallerySet Set, err error) {
gallerySet.SM, err = s.getGallery(ctx, 50)
if err != nil {
return
}
gallerySet.LG, err = s.getGallery(ctx, 80)
if err != nil {
return
}
gallerySet.XLG, err = s.getGallery(ctx, 120)
if err != nil {
return
}
return
}
func (s *Service) getGallery(ctx context.Context, width int) (gallery Gallery, err error) {
imgs, err := s.GetImages(ctx)
if err != nil {
return
}
packer := binpack2d.Create(width, math.MaxInt)
for len(imgs) > 0 {
unused := make([]Image, 0, len(imgs))
for _, img := range imgs {
adjustImageSizeStrictRatio(&img, 200)
if img.Height == 0 || img.Width == 0 {
slog.Warn("image has invalid size", "img", img)
continue
}
rect, ok := packer.Insert(img.Width, img.Height, binpack2d.RULE_BEST_SHORT_SIDE_FIT)
if !ok {
unused = append(unused, img)
continue
}
img.X = rect.X
img.Y = rect.Y
gallery.Images = append(gallery.Images, img)
}
imgs = unused
}
packer.ShrinkBin(false)
gallery.GridHeight = packer.GetHeight()
gallery.GridWith = packer.GetWidth()
return
}
// getCleanRatio returns the simplified aspect ratio as (numerator, denominator).
// It searches for the best fit within a denominator limit to account for rounding errors.
func getCleanRatio(width, height int) (int, int) {
targetRatio := float64(width) / float64(height)
const maxDenominator = 100
bestN, bestD := 0, 1
minDiff := math.MaxFloat64
for d := 1; d <= maxDenominator; d++ {
// Calculate the closest numerator for this denominator
n := int(math.Round(float64(d) * targetRatio))
// Calculate the difference
currentRatio := float64(n) / float64(d)
diff := math.Abs(currentRatio - targetRatio)
// Keep track of the one with the smallest error
if diff < minDiff {
minDiff = diff
bestN, bestD = n, d
}
}
return bestN, bestD
}
func adjustImageSizeStrictRatio(img *Image, targetArea int) {
baseW, baseH := getCleanRatio(img.Width, img.Height)
baseArea := baseW * baseH
idealK := math.Sqrt(float64(targetArea) / float64(baseArea))
k := int(math.Floor(idealK))
if math.Abs(float64(k*k*baseArea-targetArea)) >
math.Abs(float64(int(math.Ceil(idealK))*int(math.Ceil(idealK))*baseArea-targetArea)) {
k = int(math.Ceil(idealK))
}
slog.Debug("adslkfj", "k", k, "idealK", idealK, "baseH", baseH, "baseW", baseW, "imgW", img.Width, "imgH", img.Height)
// 5. Apply the scaled dimensions (guarantees perfect aspect ratio)
img.Width = baseW * k
img.Height = baseH * k
}
+40
View File
@@ -0,0 +1,40 @@
package gallery
import (
"os"
"path"
)
type fileWithPath struct {
os.FileInfo
path string
}
func (f fileWithPath) Name() string {
return path.Join(f.path, f.FileInfo.Name())
}
func (s *Service) readDirRecursive(path string) (files []os.FileInfo, err error) {
return s.readDirRecursiveTo([]os.FileInfo{}, path)
}
func (s *Service) readDirRecursiveTo(files []os.FileInfo, p ...string) ([]os.FileInfo, error) {
localFiles, err := s.fs.ReadDir(path.Join(p...))
if err != nil {
return files, err
}
for _, file := range localFiles {
if file.IsDir() {
newP := append(p, file.Name())
files, err = s.readDirRecursiveTo(files, newP...)
if err != nil {
return files, err
}
} else {
files = append(files, fileWithPath{file, path.Join(p[1:]...)})
}
}
return files, nil
}
+156
View File
@@ -0,0 +1,156 @@
package gallery
import (
"bytes"
"context"
"fmt"
"hash/fnv"
"log/slog"
"os"
"path"
"strings"
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
"github.com/valkey-io/valkey-go"
)
type Image struct {
UID string
X int
Y int
Width int
Height int
}
func (i Image) URL() string {
return images.URLFromUID(i.UID)
}
const (
imagesKey = "gallery:images:list"
hashKey = "gallery:images:hash"
)
func (s *Service) autoRefresh() {
t := time.NewTicker(time.Minute)
for {
ctx, cancle := context.WithTimeout(context.Background(), time.Minute)
if err := s.assembleImageList(ctx); err != nil {
slog.Error("error while refreshing gallery", "err", err)
}
cancle()
<-t.C
}
}
func (s *Service) assembleImageList(ctx context.Context) error {
files, err := s.readDirRecursive(s.cfg.Path)
if err != nil {
return fmt.Errorf("could not read contents of %s: %w", s.cfg.Path, err)
}
savedHash, _ := s.cache.Do(ctx, s.cache.B().Get().Key(hashKey).Build()).AsBytes()
newHash := hashImageList(files)
if bytes.Equal(savedHash, newHash) {
slog.Debug("images up to date no refreshing of gallery", "savedHash", savedHash, "newHash", newHash)
return nil
}
slog.Info("starting gallery refresh")
defer slog.Info("gallery refresh completed")
imgs := make([]Image, 0, len(files))
for _, file := range files {
if strings.HasSuffix(file.Name(), ".md") {
continue
}
imagePath := path.Join(s.cfg.Path, file.Name())
img, err := s.img.GetImage(imagePath)
if err != nil || img.Image == nil {
slog.Warn("could not get image for gallery", "err", err, "img", img)
continue
}
uid, err := img.UID()
if err != nil {
continue
}
w := img.Bounds().Dx()
h := img.Bounds().Dy()
// FIX 2: Defensive check to guard against unreadable/corrupted image boundaries
if w <= 0 || h <= 0 {
slog.Warn("skipping image with zero or negative dimensions", "path", imagePath, "w", w, "h", h)
continue
}
imgs = append(imgs, Image{
UID: uid,
Width: w,
Height: h,
})
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(hashKey).Value(valkey.BinaryString(newHash)).Build()).Error(); err != nil {
return err
}
if err = s.cache.Do(ctx, s.cache.B().Set().Key(imagesKey).Value(valkey.JSON(imgs)).Build()).Error(); err != nil {
return err
}
return nil
}
func hashImageList(files []os.FileInfo) []byte {
h := fnv.New128()
for _, file := range files {
fmt.Fprint(h, file.ModTime())
fmt.Fprint(h, file.Size())
fmt.Fprint(h, file.Name())
}
return h.Sum([]byte{})
}
func (s *Service) GetImages(ctx context.Context) (images []Image, err error) {
err = s.cache.Do(ctx, s.cache.B().Get().Key(imagesKey).Build()).DecodeJSON(&images)
return
}
func (s *Service) GetPrevAndNext(ctx context.Context, uid string) (previous *Image, next *Image, err error) {
imgs, err := s.GetImages(ctx)
if err != nil {
return nil, nil, err
}
for i := range imgs {
if imgs[i].UID != uid {
continue
}
if i > 0 {
prevImg := imgs[i-1]
previous = &prevImg
}
if i+1 < len(imgs) {
nextImg := imgs[i+1]
next = &nextImg
}
return previous, next, nil
}
return nil, nil, &rest.Error{
Type: rest.ErrorTypeNotFound,
Message: "could not find image with uid",
}
}
+35
View File
@@ -0,0 +1,35 @@
package gallery
import (
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"github.com/valkey-io/valkey-go"
)
type getImager interface {
GetImage(path string) (img images.Image, err error)
}
type Service struct {
fs filesystem.FS
cfg *config.Gallery
img getImager
cache valkey.Client
refreshIntervall time.Duration
}
func New(fs filesystem.FS, cfg *config.Gallery, img getImager, cache valkey.Client) *Service {
s := &Service{
fs: fs,
cfg: cfg,
img: img,
cache: cache,
}
go s.autoRefresh()
return s
}
+34 -10
View File
@@ -10,18 +10,18 @@ import (
"hash/fnv" "hash/fnv"
"image" "image"
"image/jpeg" "image/jpeg"
_ "image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"strings" "strings"
"time"
_ "golang.org/x/image/webp"
"github.com/valkey-io/valkey-go" "github.com/valkey-io/valkey-go"
) )
var ErrNotAnImage = errors.New("not an image") var ErrNotAnImage = errors.New("not an image")
func (s *Service) getMime(path string) (mimeType string, err error) { func (s *Service) GetMime(path string) (mimeType string, err error) {
info, err := s.fs.Stat(path) info, err := s.fs.Stat(path)
if err != nil { if err != nil {
err = fmt.Errorf("image file info could not be fetched: %w", err) err = fmt.Errorf("image file info could not be fetched: %w", err)
@@ -29,18 +29,18 @@ func (s *Service) getMime(path string) (mimeType string, err error) {
if mimer, ok := info.(interface{ ContentType() string }); ok { if mimer, ok := info.(interface{ ContentType() string }); ok {
mimeType = mimer.ContentType() mimeType = mimer.ContentType()
return
} }
return return
} }
func (s *Service) getImage(uid string) (file []byte, mimeType string, err error) { func (s *Service) getImage(path string) (file []byte, mimeType string, err error) {
path, err := PathFromUID(uid)
if err != nil { if err != nil {
err = fmt.Errorf("could not get path: %w", err) err = fmt.Errorf("could not get path: %w", err)
return return
} }
mimeType, err = s.getMime(path) mimeType, err = s.GetMime(path)
if err != nil { if err != nil {
return return
} }
@@ -58,8 +58,32 @@ func (s *Service) getImage(uid string) (file []byte, mimeType string, err error)
return return
} }
func (s *Service) GetImage(ctx context.Context, uid string, options Options) (img io.Reader, mimeType string, err error) { func (s *Service) GetImage(path string) (img Image, err error) {
rawImage, mimeType, err := s.getImage(uid) rawImage, _, err := s.getImage(path)
if err != nil {
return
}
img.Path = path
img.Image, _, err = image.Decode(bytes.NewBuffer(rawImage))
return
}
func (s *Service) GetScaledImage(ctx context.Context, uid string, options Options) (img io.Reader, mimeType string, err error) {
if err = s.seph.Acquire(ctx, 1); err != nil {
err = fmt.Errorf("could not Acquire semaphore: %w", err)
return
}
defer s.seph.Release(1)
path, err := PathFromUID(uid)
if err != nil {
return
}
rawImage, mimeType, err := s.getImage(path)
if err != nil { if err != nil {
return return
} }
@@ -82,14 +106,14 @@ func (s *Service) GetImage(ctx context.Context, uid string, options Options) (im
outBuff := bytes.NewBuffer(make([]byte, 0, 1024)) outBuff := bytes.NewBuffer(make([]byte, 0, 1024))
if options.Quality == 0 { if options.Quality == 0 {
options.Quality = 80 options.Quality = s.cfg.Quality
} }
err = jpeg.Encode(outBuff, decImage, &jpeg.Options{Quality: options.Quality}) err = jpeg.Encode(outBuff, decImage, &jpeg.Options{Quality: options.Quality})
if err != nil { if err != nil {
return return
} }
if err = s.cache.Do(ctx, s.cache.B().Set().Key("img:"+hash).Value(valkey.BinaryString(outBuff.Bytes())).Ex(time.Hour*48).Build()).Error(); err == nil { if err = s.cache.Do(ctx, s.cache.B().Set().Key("img:"+hash).Value(valkey.BinaryString(outBuff.Bytes())).Ex(s.cfg.CacheLifetime).Build()).Error(); err == nil {
return outBuff, "image/jpeg", err return outBuff, "image/jpeg", err
} }
@@ -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.Image{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.GetScaledImage(b.Context(), uid, images.Options{Width: 500, Quality: 80})
if err != nil {
b.Error(err)
}
}
}
+19 -7
View File
@@ -9,6 +9,11 @@ import (
"strings" "strings"
) )
const (
sourceSetSteps = 300
sourceSetMax = 1500
)
func UIDFromPath(path string) (uid string, err error) { func UIDFromPath(path string) (uid string, err error) {
var b bytes.Buffer var b bytes.Buffer
// NewWriter with NoDict (nil) creates a raw DEFLATE compressor // NewWriter with NoDict (nil) creates a raw DEFLATE compressor
@@ -28,6 +33,11 @@ func UIDFromPath(path string) (uid string, err error) {
func URLFromPath(path string) (url string, err error) { func URLFromPath(path string) (url string, err error) {
uid, err := UIDFromPath(path) uid, err := UIDFromPath(path)
url = URLFromUID(uid)
return
}
func URLFromUID(uid string) (url string) {
url = "/images/" + uid url = "/images/" + uid
return return
} }
@@ -57,12 +67,14 @@ func PathFromUID(uid string) (string, error) {
func SourceSet(src string) string { func SourceSet(src string) string {
sb := strings.Builder{} sb := strings.Builder{}
for i := range 19 { i := 1
sb.WriteString(fmt.Sprintf("%s?w=%d %dw, ", src, 100*(i+1), 100*(i+1))) for {
} width := i * sourceSetSteps
sb.WriteString(fmt.Sprintf("%s?w=%d %dw, ", src, width, width))
i := 20 if width >= sourceSetMax {
sb.WriteString(fmt.Sprintf("%s?w=%d %dw", src, 100*(i+1), 100*(i+1)))
return sb.String() return sb.String()
}
sb.WriteRune(',')
i++
}
} }
+12
View File
@@ -0,0 +1,12 @@
package images
import "image"
type Image struct {
image.Image
Path string `json:"path"`
}
func (i Image) UID() (string, error) {
return UIDFromPath(i.Path)
}
+7 -1
View File
@@ -3,7 +3,9 @@ package images
import ( import (
"os" "os"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"github.com/valkey-io/valkey-go" "github.com/valkey-io/valkey-go"
"golang.org/x/sync/semaphore"
) )
type Options struct { type Options struct {
@@ -15,12 +17,16 @@ type Options struct {
type Service struct { type Service struct {
fs fileSystem fs fileSystem
cache valkey.Client cache valkey.Client
seph *semaphore.Weighted
cfg *config.Image
} }
func New(fs fileSystem, cache valkey.Client) *Service { func New(fs fileSystem, cfg *config.Image, cache valkey.Client) *Service {
return &Service{ return &Service{
fs: fs, fs: fs,
cache: cache, cache: cache,
seph: semaphore.NewWeighted(cfg.Concurency),
cfg: cfg,
} }
} }
+1 -1
View File
@@ -14,7 +14,7 @@ func scale(src image.Image, options Options) image.Image {
) )
// Resize: // Resize:
draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil) draw.ApproxBiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
// Encode to `output`: // Encode to `output`:
return dst return dst
+3 -1
View File
@@ -16,6 +16,7 @@ type PageHeader struct {
type Page struct { type Page struct {
PageHeader PageHeader
Content string Content string
Images []Image
} }
func (s *Service) GetPages(ctx context.Context) (pages []PageHeader, err error) { 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 return
} }
html, err := s.getHTML(ctx, p) html, imgs, err := s.getHTML(ctx, p)
if err != nil { if err != nil {
return return
} }
@@ -54,6 +55,7 @@ func (s *Service) GetPage(ctx context.Context, uid string) (page Page, err error
page = Page{ page = Page{
PageHeader: p, PageHeader: p,
Content: string(html), Content: string(html),
Images: imgs,
} }
return return
} }
+20 -5
View File
@@ -14,7 +14,13 @@ import (
"github.com/gomarkdown/markdown/parser" "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 extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions) 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 htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{ opts := html.RendererOptions{
Flags: htmlFlags, Flags: htmlFlags,
RenderNodeHook: imageMiddleware(page), RenderNodeHook: imageMiddleware(page, &imgs),
} }
renderer := html.NewRenderer(opts) renderer := html.NewRenderer(opts)
@@ -32,7 +38,7 @@ func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, err
return 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) { return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
if !entering { if !entering {
return ast.GoToNext, false return ast.GoToNext, false
@@ -53,11 +59,20 @@ func imageMiddleware(page PageHeader) func(w io.Writer, node ast.Node, entering
slog.Error("could not get image url", "err", err) slog.Error("could not get image url", "err", err)
return ast.GoToNext, false return ast.GoToNext, false
} }
image := Image{
SrcSet: images.SourceSet(src),
Src: src + "?w=1024",
}
img.Attribute = &ast.Attribute{Attrs: map[string][]byte{ img.Attribute = &ast.Attribute{Attrs: map[string][]byte{
"srcset": []byte(images.SourceSet(src)), // "srcset": []byte(image.SrcSet),
"src": []byte(src), "fetchpriority": []byte("low"),
"loading": []byte("lazy"),
"class": []byte("min-h-48"),
"src": []byte(image.Src),
}} }}
*imgs = append(*imgs, image)
// // img.Attrs["srcset"] = // // img.Attrs["srcset"] =
} }
+5 -3
View File
@@ -4,13 +4,12 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"regexp" "regexp"
"strings" "strings"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"github.com/studio-b12/gowebdav" "github.com/studio-b12/gowebdav"
) )
@@ -57,12 +56,15 @@ func (s *Service) getPageHeaders(ctx context.Context, uid string) (page PageHead
} }
} }
slog.Debug("loaded page infos", "cover", coverImage, "content", contentMD) if contentMD != nil {
page.md, err = s.fs.Read(contentMD.Path()) page.md, err = s.fs.Read(contentMD.Path())
if err != nil { if err != nil {
return return
} }
} else {
page.md = []byte("# ...")
}
title := titleRGX.FindStringSubmatch(string(page.md)) title := titleRGX.FindStringSubmatch(string(page.md))
if len(title) < 2 { if len(title) < 2 {
+1 -1
View File
@@ -1,7 +1,7 @@
package page package page
import ( import (
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
) )
type Service struct { type Service struct {
+10 -2
View File
@@ -25,6 +25,7 @@ func SupportedLanguages(ctx context.Context) []string {
s, ok := ctx.Value(serviceKey).(*Service) s, ok := ctx.Value(serviceKey).(*Service)
if !ok { if !ok {
slog.Error("could not extract translation service from context for getting supported languages") slog.Error("could not extract translation service from context for getting supported languages")
return []string{}
} }
return s.SupportedLanguages() return s.SupportedLanguages()
} }
@@ -52,20 +53,27 @@ func Path(ctx context.Context, pp ...string) string {
} }
func PathForLang(ctx context.Context, lang string, pp ...string) string { func PathForLang(ctx context.Context, lang string, pp ...string) string {
slog.Info("path for lang", "path", pp) slog.Debug("computing path for lang", "pp", pp, "lang", lang)
s, ok := ctx.Value(serviceKey).(*Service) s, ok := ctx.Value(serviceKey).(*Service)
if !ok { if !ok {
slog.Error("could not extract translation service from context for translating path", "ctx", ctx, "path", pp) slog.Error("could not extract translation service from context for translating path", "ctx", ctx, "path", pp)
return path.Join(pp...) return path.Join(pp...)
} }
// remove leading ""
for len(pp) > 0 && pp[0] == "" {
pp = pp[1:]
}
if len(pp) > 0 { if len(pp) > 0 {
slog.Debug("computing path for lang", "pp", pp, "lang", lang, "pp[0]", pp[0], "translations", s.translations)
if _, ok := s.translations[pp[0]]; ok { if _, ok := s.translations[pp[0]]; ok {
pp = pp[1:] pp = pp[1:]
} }
} }
return path.Join(append([]string{lang}, pp...)...) return "/" + path.Join(append([]string{lang}, pp...)...)
} }
func (s *Service) SupportedLanguages() (languages []string) { func (s *Service) SupportedLanguages() (languages []string) {
@@ -1,4 +1,4 @@
package restutil package rest
import ( import (
"encoding/json" "encoding/json"
@@ -1,4 +1,4 @@
package restutil package rest
import ( import (
"fmt" "fmt"
+13
View File
@@ -0,0 +1,13 @@
package rest
import "net/http"
type Middeware = func(next http.Handler) http.Handler
func Use(handler http.Handler, middlewares ...Middeware) http.Handler {
for _, middleware := range middlewares {
handler = middleware(handler)
}
return handler
}
@@ -0,0 +1,45 @@
package restgallery
import (
"fmt"
"log/slog"
"net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages"
)
const preloadCount = 6
func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) {
imgs, err := h.srv.GetGallerySet(r.Context())
if err != nil {
slog.Error("could not get gallery set", "err", err)
rest.SendErr(w, err)
return
}
ctx := r.Context()
for i, image := range imgs.LG.Images {
if i > preloadCount {
break
}
ctx = templer.AppendHeader(ctx, fmt.Sprintf(`<link rel="preload" href="%s?w=800" as="image" />`, image.URL()))
}
h.r.RenderPage(ctx, w, r, pages.GallerySet(imgs))
}
func (h *Handler) image(w http.ResponseWriter, r *http.Request) {
uid := r.PathValue("image_uid")
slog.Debug("image from gallery", "uid", uid)
prev, next, err := h.srv.GetPrevAndNext(r.Context(), uid)
if err != nil {
slog.Error("could not get gallery", "err", err)
rest.SendErr(w, err)
return
}
h.r.RenderComponent(r.Context(), w, r, pages.GalleryImage(uid, next, prev))
}
+45
View File
@@ -0,0 +1,45 @@
package restgallery
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"github.com/a-h/templ"
)
type Handler struct {
http.Handler
srv galleryService
r renderer
cfg *config.Gallery
}
func New(renderer renderer, srv galleryService, cfg *config.Gallery) *Handler {
mux := http.NewServeMux()
h := &Handler{
Handler: mux,
srv: srv,
r: renderer,
cfg: cfg,
}
galleryPath := url.PathEscape(cfg.Path)
mux.HandleFunc(fmt.Sprintf("GET /%s/{image_uid}", galleryPath), h.image)
mux.HandleFunc(fmt.Sprintf("GET /%s", galleryPath), h.gallery)
return h
}
type galleryService interface {
GetGallerySet(ctx context.Context) (gallerySet gallery.Set, err error)
GetPrevAndNext(ctx context.Context, uid string) (previous *gallery.Image, next *gallery.Image, err error)
}
type renderer interface {
RenderPage(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
RenderComponent(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
}
+20 -3
View File
@@ -1,9 +1,12 @@
package resthome package resthome
import ( import (
"context"
"errors"
"fmt"
"net/http" "net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/layouts" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages"
) )
@@ -14,17 +17,31 @@ func (h *Handler) home(w http.ResponseWriter, r *http.Request) {
return return
} }
layouts.Render(r.Context(), w, r, pages.Home(pageHeaders)) h.r.RenderPage(r.Context(), w, r, pages.Home(pageHeaders))
} }
func (h *Handler) page(w http.ResponseWriter, r *http.Request) { func (h *Handler) page(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := r.PathValue("uid") uid := r.PathValue("uid")
page, err := h.src.GetPage(r.Context(), uid) page, err := h.src.GetPage(r.Context(), uid)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
w.WriteHeader(http.StatusRequestTimeout)
}
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
layouts.Render(r.Context(), w, r, pages.ContentPage(page)) for i, image := range page.Images {
if i > 3 {
break
}
ctx = templer.AppendHeader(ctx, fmt.Sprintf(`<link rel="preload" href="%s" as="image" />`, image.Src))
}
ctx = templer.PageTitle(ctx, page.Title)
h.r.RenderPage(ctx, w, r, pages.ContentPage(page))
} }
+11 -1
View File
@@ -2,18 +2,25 @@ package resthome
import ( import (
"context" "context"
"io"
"net/http" "net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
"github.com/a-h/templ"
) )
type Handler struct { type Handler struct {
http.Handler
src pageService src pageService
r renderer
} }
func New(mux *http.ServeMux, srv pageService) *Handler { func New(renderer renderer, srv pageService) *Handler {
mux := http.NewServeMux()
h := &Handler{ h := &Handler{
Handler: mux,
src: srv, src: srv,
r: renderer,
} }
mux.HandleFunc("GET /{$}", h.home) mux.HandleFunc("GET /{$}", h.home)
@@ -25,3 +32,6 @@ type pageService interface {
GetPages(ctx context.Context) ([]page.PageHeader, error) GetPages(ctx context.Context) ([]page.PageHeader, error)
GetPage(ctx context.Context, uid string) (page.Page, error) GetPage(ctx context.Context, uid string) (page.Page, error)
} }
type renderer interface {
RenderPage(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
}
@@ -6,7 +6,7 @@ import (
"net/http" "net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restutil" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
) )
func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) { func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) {
@@ -14,24 +14,24 @@ func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) {
var err error var err error
options := images.Options{} options := images.Options{}
options.Height, err = restutil.IntParam(r, "h") options.Height, err = rest.IntParam(r, "h")
if err != nil { if err != nil {
restutil.SendErr(w, err) rest.SendErr(w, err)
return return
} }
options.Width, err = restutil.IntParam(r, "w") options.Width, err = rest.IntParam(r, "w")
if err != nil { if err != nil {
restutil.SendErr(w, err) rest.SendErr(w, err)
return return
} }
options.Quality, err = restutil.IntParam(r, "q") options.Quality, err = rest.IntParam(r, "q")
if err != nil { if err != nil {
restutil.SendErr(w, err) rest.SendErr(w, err)
return return
} }
img, mime, err := h.img.GetImage(r.Context(), uid, options) img, mime, err := h.img.GetScaledImage(r.Context(), uid, options)
if err != nil { if err != nil {
slog.Error("error wile serving image", "err", err, "uid", uid) slog.Error("error wile serving image", "err", err, "uid", uid)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
+5 -2
View File
@@ -9,11 +9,14 @@ import (
) )
type Handler struct { type Handler struct {
http.Handler
img imageService img imageService
} }
func New(mux *http.ServeMux, srv imageService) *Handler { func New(srv imageService) *Handler {
mux := http.NewServeMux()
h := &Handler{ h := &Handler{
Handler: mux,
img: srv, img: srv,
} }
@@ -22,5 +25,5 @@ func New(mux *http.ServeMux, srv imageService) *Handler {
} }
type imageService interface { type imageService interface {
GetImage(ctx context.Context, uid string, options images.Options) (img io.Reader, mimeType string, err error) GetScaledImage(ctx context.Context, uid string, options images.Options) (img io.Reader, mimeType string, err error)
} }
+27
View File
@@ -0,0 +1,27 @@
package config
import "time"
func Default() Cfg {
return Cfg{
Image: &Image{
Concurency: 10,
Quality: 80,
CacheLifetime: time.Hour * 24 * 31,
},
Tracking: &Umami{
ScriptSrc: "https://umami.schreifuchs.ch/script.js",
RecorderSrc: "https://umami.schreifuchs.ch/recorder.js",
WebsiteID: "54d8a379-77d5-4c20-b46d-5c9a6c9e39bd",
PerformanceEnabled: true,
},
Gallery: &Gallery{
Path: "photos",
},
Cache: &Valkey{
RefreshTime: time.Minute * 10,
LifeTime: time.Hour * 48,
},
LogJSON: true,
}
}
+81
View File
@@ -0,0 +1,81 @@
package config
import (
"context"
"log/slog"
"time"
"github.com/sethvargo/go-envconfig"
)
type Cfg struct {
FileSystem *WebdavConfig `env:", prefix=FILESYSTEM_"`
Cache *Valkey `env:", prefix=CACHE_"`
Image *Image `env:", prefix=IMAGE_"`
Tracking *Umami `env:", prefix=TRACKING_"`
Gallery *Gallery `env:", prefix=GALLERY_"`
LogLevel slog.Level `env:"LOG_LEVEL, default=DEBUG"`
LogJSON bool `env:"LOG_JSON"`
}
type WebdavConfig struct {
URL string `env:"URL"`
User string `env:"USERNAME"`
Password string `env:"PASSWORD"`
}
type Valkey struct {
ClientName string `env:"CLIENTNAME"`
Username string `env:"USERNAME"`
Password string `env:"PASSWORD"`
RefreshTime time.Duration `env:"REFRESH_TIME"`
LifeTime time.Duration `env:"LIFE_TIME"`
// InitAddress point to valkey nodes.
// Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success.
// If len(InitAddress) == 1 and the address is not running in cluster mode, valkey will fall back to the single client mode.
// If ClientOption.Sentinel.MasterSet is set, then InitAddress will be used to connect sentinels
// You can bypass this behavior by using ClientOption.ForceSingleClient.
InitAddress []string `env:"ADDRESS"`
}
type Image struct {
Concurency int64 `env:"CONCURENCY"`
Quality int `env:"QUALITY"`
CacheLifetime time.Duration `env:"CACHE_LIFETIME"`
}
type Gallery struct {
Path string `env:"PATH"`
}
type Umami struct {
ScriptSrc string `env:"SCRIPT_SRC"`
RecorderSrc string `env:"RECORDER_SRC"`
WebsiteID string `env:"WEBSITE_ID"`
PerformanceEnabled bool `env:"PERFORMANCE_ENABLED`
}
func Read(ctx context.Context) (cfg Cfg, err error) {
cfg = Default()
err = envconfig.ProcessWith(ctx, &envconfig.Config{
Target: &cfg,
// All fields will use a ";" delimiter and "@" separator, unless locally
// overridden.
DefaultDelimiter: ";",
DefaultSeparator: "@",
DefaultOverwrite: true,
Lookuper: envconfig.MultiLookuper(secretLookuper(), envconfig.OsLookuper()),
})
if err != nil {
return
}
slog.Info("rft", "rft", cfg.Cache.RefreshTime)
return
}
+36
View File
@@ -0,0 +1,36 @@
package config
import (
"io"
"log/slog"
"os"
"strings"
"github.com/sethvargo/go-envconfig"
)
func secretLookuper() envconfig.LookuperFunc {
return func(key string) (string, bool) {
fileName := os.Getenv(key + "_FILE")
if fileName == "" {
return "", false
}
file, err := os.Open(fileName)
if err != nil {
slog.Error("could not read secret file", "path", fileName)
return "", false
}
defer file.Close()
sb := &strings.Builder{}
_, err = io.Copy(sb, file)
if err != nil {
return "", false
}
return sb.String(), true
}
}
@@ -23,11 +23,11 @@ type CachedClient struct {
} }
// NewCachedClient creates a new CachedClient. // NewCachedClient creates a new CachedClient.
func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *CachedClient { func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration, lifetime time.Duration) *CachedClient {
c := &CachedClient{ c := &CachedClient{
impl: impl, impl: impl,
cache: client, cache: client,
ttl: refreshtime * 5, ttl: lifetime,
} }
go func() { go func() {
@@ -42,6 +42,7 @@ func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *
} }
func (c *CachedClient) revalidate(ctx context.Context) { func (c *CachedClient) revalidate(ctx context.Context) {
slog.Info("starting revalidation")
go c.revalidateReadDir(ctx) go c.revalidateReadDir(ctx)
go c.revalidateRead(ctx) go c.revalidateRead(ctx)
go c.revalidateStat(ctx) go c.revalidateStat(ctx)
File renamed without changes.
@@ -150,7 +150,7 @@ func TestCachedClient_Stat(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/test.txt", []byte("hello")) mockFS.AddFile("/test.txt", []byte("hello"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Cache miss // First call: Cache miss
info, err := c.Stat("/test.txt") info, err := c.Stat("/test.txt")
@@ -209,7 +209,7 @@ func TestCachedClient_Read(t *testing.T) {
content := []byte("hello world") content := []byte("hello world")
mockFS.AddFile("/read.txt", content) mockFS.AddFile("/read.txt", content)
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Miss // First call: Miss
data, err := c.Read("/read.txt") data, err := c.Read("/read.txt")
@@ -257,7 +257,7 @@ func TestCachedClient_ReadDir(t *testing.T) {
mockFS.AddFile("/dir/f1.txt", []byte("1")) mockFS.AddFile("/dir/f1.txt", []byte("1"))
mockFS.AddFile("/dir/f2.txt", []byte("2")) mockFS.AddFile("/dir/f2.txt", []byte("2"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// First call: Miss // First call: Miss
infos, err := c.ReadDir("/dir") infos, err := c.ReadDir("/dir")
@@ -303,7 +303,7 @@ func TestCachedClient_RevalidateStat(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/reval.txt", []byte("initial")) mockFS.AddFile("/reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, _ = c.Stat("/reval.txt") _, _ = c.Stat("/reval.txt")
@@ -381,7 +381,7 @@ func TestCachedClient_RevalidateRead(t *testing.T) {
mockFS := NewMockFS() mockFS := NewMockFS()
mockFS.AddFile("/read_reval.txt", []byte("initial")) mockFS.AddFile("/read_reval.txt", []byte("initial"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, _ = c.Read("/read_reval.txt") _, _ = c.Read("/read_reval.txt")
@@ -431,7 +431,7 @@ func TestCachedClient_RevalidateReadDir(t *testing.T) {
mockFS.AddDir("/dir_reval") mockFS.AddDir("/dir_reval")
mockFS.AddFile("/dir_reval/f1.txt", []byte("1")) mockFS.AddFile("/dir_reval/f1.txt", []byte("1"))
c := NewCachedClient(mockFS, client, time.Hour) c := NewCachedClient(mockFS, client, time.Hour, time.Hour*2)
// Populate cache // Populate cache
_, err = c.ReadDir("/dir_reval") _, err = c.ReadDir("/dir_reval")
@@ -461,4 +461,3 @@ func TestCachedClient_RevalidateReadDir(t *testing.T) {
t.Errorf("expected 1 hit, got %d", c.hits.Load()) t.Errorf("expected 1 hit, got %d", c.hits.Load())
} }
} }
File renamed without changes.
@@ -26,7 +26,6 @@ func (c *CachedClient) Read(path string) ([]byte, error) {
if err == nil && len(cached) > 0 { if err == nil && len(cached) > 0 {
c.readHits.Add(1) c.readHits.Add(1)
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
var file readFile var file readFile
err := json.Unmarshal(cached, &file) err := json.Unmarshal(cached, &file)
@@ -22,7 +22,6 @@ func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) {
if err := json.Unmarshal([]byte(val), &cached); err == nil { if err := json.Unmarshal([]byte(val), &cached); err == nil {
c.readDirHits.Add(1) c.readDirHits.Add(1)
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
infos := make([]os.FileInfo, len(cached)) infos := make([]os.FileInfo, len(cached))
for i, f := range cached { for i, f := range cached {
infos[i] = f infos[i] = f
@@ -19,7 +19,6 @@ func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) {
var f File var f File
if err := json.Unmarshal([]byte(val), &f); err == nil { if err := json.Unmarshal([]byte(val), &f); err == nil {
c.hits.Add(1) c.hits.Add(1)
slog.Debug("cache hit", "key", key)
return f, nil return f, nil
} }
+46
View File
@@ -0,0 +1,46 @@
package middleware
import (
"fmt"
"log/slog"
"net/http"
"time"
)
type captureWriter struct {
http.ResponseWriter
code int
}
func (c *captureWriter) WriteHeader(statusCode int) {
c.code = statusCode
c.ResponseWriter.WriteHeader(statusCode)
}
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
cw := &captureWriter{
ResponseWriter: w,
code: 200,
}
next.ServeHTTP(cw, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start),
"status", cw.code,
)
})
}
// cacheMiddleware adds Cache-Control headers to the response.
func Cache(maxAge time.Duration) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds()))
w.Header().Set("Vary", "HX-Request,Accept-Language,Accept-Encoding")
next.ServeHTTP(w, r)
})
}
}
+48
View File
@@ -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
}
+64
View File
@@ -0,0 +1,64 @@
package templer
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
)
type ctxKey int
const (
pathKey ctxKey = iota
headerKey
titleKey
)
func Middleware(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, pathKey, r.URL.Path)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
// Path returns the current path with a leading slash
func Path(ctx context.Context) string {
path, ok := ctx.Value(pathKey).(string)
if !ok {
slog.Error("could not extract request path from context")
return ""
}
return path
}
func RelativePath(ctx context.Context, relPath string) (path string, err error) {
baseStr := Path(ctx)
if baseStr != "" && !strings.HasSuffix(baseStr, "/") {
baseStr += "/"
}
base, err := url.Parse(baseStr)
if err != nil {
err = fmt.Errorf("could not parse base path: %w", err)
return
}
rel, err := url.Parse(relPath)
if err != nil {
err = fmt.Errorf("could not parse relative path: %w", err)
return
}
// Resolve the relative path against the base
path = base.ResolveReference(rel).Path
if strings.HasSuffix(path, "/") {
path = path[:len(path)-1]
}
return
}
+75
View File
@@ -0,0 +1,75 @@
package templer
import (
"context"
"testing"
)
func TestRelativePath(t *testing.T) {
tests := []struct {
name string // description of this test case
// Named input parameters for target function.
base string
relPath string
want string
wantErr bool
}{
// TODO: Add test cases.
{
name: "with no relative path stays the same",
base: "/foo/bar",
relPath: "",
want: "/foo/bar",
wantErr: false,
},
{
name: "with . relative path stays the same",
base: "/foo/bar",
relPath: ".",
want: "/foo/bar",
wantErr: false,
},
{
name: "with ./ relative path stays the same",
base: "/foo/bar",
relPath: "./",
want: "/foo/bar",
wantErr: false,
},
{
name: "with .. relative path goes one up",
base: "/foo/bar",
relPath: "..",
want: "/foo",
wantErr: false,
},
{
name: "with ../baz relative path replaces last element with baz",
base: "/foo/bar",
relPath: "../baz",
want: "/foo/baz",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.WithValue(t.Context(), pathKey, tt.base)
got, gotErr := RelativePath(ctx, tt.relPath)
if gotErr != nil {
if !tt.wantErr {
t.Errorf("RelativePath() failed: %v", gotErr)
}
return
}
if tt.wantErr {
t.Fatal("RelativePath() succeeded unexpectedly")
}
// TODO: update the condition below to compare got with tt.want.
if got != tt.want {
t.Errorf("RelativePath() = %v, want %v", got, tt.want)
}
})
}
}
+92
View File
@@ -0,0 +1,92 @@
package server
import (
"fmt"
"net/http"
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/rest"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restgallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/resthome"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restimage"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/filesystem"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/middleware"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/layouts"
"github.com/studio-b12/gowebdav"
"github.com/valkey-io/valkey-go"
)
func (s *Server) allRoutes() (h http.Handler, err error) {
mux := http.NewServeMux()
registerStatic(mux)
other, err := s.dynamicRoutes()
if err != nil {
return
}
mux.Handle("/", other)
h = mux
h = templer.Middleware(h)
h = middleware.Logging(h)
return h, nil
}
func (s *Server) dynamicRoutes() (h http.Handler, err error) {
mux := http.NewServeMux()
webdavClient := gowebdav.NewClient(s.cfg.FileSystem.URL, s.cfg.FileSystem.User, s.cfg.FileSystem.Password)
err = webdavClient.Connect()
if err != nil {
err = fmt.Errorf("could not connect webdavClient: %w", err)
return
}
var fs filesystem.FS = webdavClient
valkeyClient, err := valkey.NewClient(valkey.ClientOption{
InitAddress: s.cfg.Cache.InitAddress,
Password: s.cfg.Cache.Password,
})
if err != nil {
err = fmt.Errorf("failed to create valkey client: %w", err)
return
}
if err != nil {
} else {
fs = filesystem.NewCachedClient(webdavClient, valkeyClient, s.cfg.Cache.RefreshTime, s.cfg.Cache.LifeTime)
}
imageSrv := images.New(fs, s.cfg.Image, valkeyClient)
mux.Handle("/images/", rest.Use(
restimage.New(imageSrv),
middleware.Cache(time.Hour*24*5),
))
translator := translate.New()
renderer := layouts.New(s.cfg)
mux.Handle("/", rest.Use(
func() http.Handler {
mux := http.NewServeMux()
galleryMux := restgallery.New(renderer, gallery.New(fs, s.cfg.Gallery, imageSrv, valkeyClient), s.cfg.Gallery)
mux.Handle("/"+s.cfg.Gallery.Path, galleryMux)
mux.Handle("/"+s.cfg.Gallery.Path+"/", galleryMux)
mux.Handle("/", resthome.New(renderer, page.New(fs)))
return mux
}(),
translator.Middleware,
middleware.Cache(time.Minute),
))
return mux, nil
}
-80
View File
@@ -1,80 +0,0 @@
package server
import (
"log/slog"
"net/http"
"os"
"time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/resthome"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restimage"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
"github.com/studio-b12/gowebdav"
"github.com/valkey-io/valkey-go"
)
func (s *Server) RegisterRoutes() (h http.Handler) {
mux := http.NewServeMux()
registerStatic(mux)
mux.Handle("/", registerOther())
h = mux
h = templer.Middleware(h)
h = s.loggingMiddleware(h)
return h
}
func registerOther() (h http.Handler) {
mux := http.NewServeMux()
webdavClient := gowebdav.NewClient("https://cloud.schreifuchs.ch/remote.php/dav/files/test/website", "test", os.Getenv("TEST_PW"))
if err := webdavClient.Connect(); err != nil {
slog.Error("could not connect webdavClient", "err", err, "pw", os.Getenv("TEST_PW"))
}
var fs filesystem.FS = webdavClient
valkeyAddr := os.Getenv("VALKEY_ADDR")
if valkeyAddr == "" {
valkeyAddr = "127.0.0.1:6379"
}
valkeyClient, err := valkey.NewClient(valkey.ClientOption{
InitAddress: []string{valkeyAddr},
Password: os.Getenv("VALKEY_PASSWORD"),
})
if err != nil {
slog.Error("failed to create valkey client", "err", err)
} else {
fs = filesystem.NewCachedClient(webdavClient, valkeyClient, 5*time.Hour)
}
_ = resthome.New(mux, page.New(fs))
_ = restimage.New(mux, images.New(fs, valkeyClient))
mux.Handle("/", http.FileServer(http.FS(web.GetStaticFS())))
translator := translate.New()
h = mux
h = translator.Middleware(h)
h = cacheMiddleware(time.Second * 45)(h)
return h
}
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"duration", time.Since(start),
)
})
}
+39 -4
View File
@@ -1,34 +1,69 @@
// Package server provides the http server for schreifuchs.ch.
package server package server
import ( import (
"context"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"strconv" "strconv"
"time" "time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
) )
type Server struct { type Server struct {
port int port int
cfg config.Cfg
} }
func NewServer() *http.Server { // 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")) port, _ := strconv.Atoi(os.Getenv("PORT"))
if port == 0 { if port == 0 {
port = 8080 port = 8080
} }
s := &Server{ s := &Server{
port: port, port: port,
cfg: cfg,
}
hander, err := s.allRoutes()
if err != nil {
err = fmt.Errorf("could not setup routes: %w", err)
return
} }
// Declare Server config // Declare Server config
server := &http.Server{ srv := &http.Server{
Addr: fmt.Sprintf(":%d", s.port), Addr: fmt.Sprintf(":%d", s.port),
Handler: s.RegisterRoutes(), Handler: hander,
IdleTimeout: time.Minute, IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second, ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
} }
idleConnsClosed := make(chan struct{})
return server go func() {
<-ctx.Done()
slog.Info("shutting down server")
// Shutdown server when ctx done
if err = srv.Shutdown(context.Background()); err != nil {
// Error from closing listeners, or context timeout:
err = fmt.Errorf("error while shutting down server: %w", err)
}
close(idleConnsClosed)
}()
slog.Info("starting server", "address", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
return fmt.Errorf("error while serving: %w", err)
}
<-idleConnsClosed
return err
} }
+3 -14
View File
@@ -1,12 +1,12 @@
package server package server
import ( import (
"fmt"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"time" "time"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/middleware"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
) )
@@ -15,7 +15,7 @@ func registerStatic(mux *http.ServeMux) {
fileSystem := web.GetStaticFS() fileSystem := web.GetStaticFS()
// we still use go's fileServer to avoid unnecessary implementation // we still use go's fileServer to avoid unnecessary implementation
fileServer := cacheMiddleware(time.Hour * 24)(http.FileServer(http.FS(fileSystem))) fileServer := middleware.Cache(time.Hour * 24)(http.FileServer(http.FS(fileSystem)))
err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error { err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil { if err != nil {
@@ -27,7 +27,7 @@ func registerStatic(mux *http.ServeMux) {
path = "/" + path path = "/" + path
slog.Info("registering file", "path", path) slog.Debug("registered static file", "path", path)
mux.Handle(path, fileServer) mux.Handle(path, fileServer)
@@ -38,14 +38,3 @@ func registerStatic(mux *http.ServeMux) {
panic(err) panic(err)
} }
} }
// cacheMiddleware adds Cache-Control headers to the response.
func cacheMiddleware(maxAge time.Duration) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds()))
w.Header().Set("Vary", "HX-Request,Accept-Language,Accept-Encoding")
next.ServeHTTP(w, r)
})
}
}
-34
View File
@@ -1,34 +0,0 @@
package templer
import (
"context"
"log/slog"
"net/http"
"strings"
)
type ctxKey int
const (
pathKey ctxKey = iota
)
func Middleware(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, pathKey, r.URL.Path)
next.ServeHTTP(w, r.WithContext(ctx))
}
}
// Path returns the current path without a leading slash
func Path(ctx context.Context) string {
path, ok := ctx.Value(pathKey).(string)
if !ok {
slog.Error("could not extract request path from context")
return ""
}
return strings.TrimPrefix(path, "/")
}
+5 -4
View File
@@ -4,9 +4,9 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --minify", "build:css": "tailwindcss -i ./web/src/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", "watch:css": "tailwindcss -i ./web/src/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" "build:js": "node scripts/build.mjs"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@@ -16,10 +16,11 @@
"@tailwindcss/cli": "^4.1.18", "@tailwindcss/cli": "^4.1.18",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"esbuild": "^0.27.4",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"dependencies": { "dependencies": {
"htmx.org": "^2.0.8" "htmx.org": "^4.0.0"
} }
} }
+277 -5
View File
@@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
htmx.org: htmx.org:
specifier: ^2.0.8 specifier: ^4.0.0
version: 2.0.8 version: 4.0.0
devDependencies: devDependencies:
'@tailwindcss/cli': '@tailwindcss/cli':
specifier: ^4.1.18 specifier: ^4.1.18
@@ -21,6 +21,9 @@ importers:
autoprefixer: autoprefixer:
specifier: ^10.4.24 specifier: ^10.4.24
version: 10.4.24(postcss@8.5.6) version: 10.4.24(postcss@8.5.6)
esbuild:
specifier: ^0.27.4
version: 0.27.4
postcss: postcss:
specifier: ^8.5.6 specifier: ^8.5.6
version: 8.5.6 version: 8.5.6
@@ -30,6 +33,162 @@ importers:
packages: packages:
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -267,6 +426,11 @@ packages:
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0: escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -277,8 +441,9 @@ packages:
graceful-fs@4.2.11: graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
htmx.org@2.0.8: htmx.org@4.0.0:
resolution: {integrity: sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==} resolution: {integrity: sha512-T/171FUY93Kdfp8t+DnHdk45QvKRiBhVhhrwSzrXgUi4pHKvhp77dUA/qg8FAjsFWPIHNbmUuIdCrcVHuiZWng==}
hasBin: true
is-extglob@2.1.1: is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
@@ -424,6 +589,84 @@ packages:
snapshots: snapshots:
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
'@jridgewell/gen-mapping@0.3.13': '@jridgewell/gen-mapping@0.3.13':
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -611,13 +854,42 @@ snapshots:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
tapable: 2.3.0 tapable: 2.3.0
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
escalade@3.2.0: {} escalade@3.2.0: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
graceful-fs@4.2.11: {} graceful-fs@4.2.11: {}
htmx.org@2.0.8: {} htmx.org@4.0.0: {}
is-extglob@2.1.1: {} is-extglob@2.1.1: {}
+69
View File
@@ -0,0 +1,69 @@
import * as esbuild from "esbuild";
import fs from "node:fs/promises";
import path from "node:path";
const filesToCopy = [
"node_modules/htmx.org/dist/htmx.min.js",
"node_modules/htmx.org/dist/ext/hx-preload.min.js",
"node_modules/htmx.org/dist/ext/hx-head.min.js",
];
const filesToMinifyRoot = "web/src/js";
const outputRoot = "web/static/js";
async function minifyFiles() {
try {
const files = (await fs.readdir(filesToMinifyRoot))
.filter((p) => p.endsWith(".js"))
.map((p) => {
return {
src: path.join(filesToMinifyRoot, p),
dest: path.join(outputRoot, p.substring(0, p.length - 3) + ".min.js"),
};
});
for (const file of files) {
await esbuild.build({
entryPoints: [file.src],
bundle: false,
minify: true,
outfile: file.dest,
});
console.log(`Minified ${file.src} to ${file.dest}`);
}
} catch (err) {
console.error("could not minify files", err);
}
}
async function coppyFromNodemodules() {
try {
const fileMap = filesToCopy.map((file) => {
return {
src: file,
dest: path.join(outputRoot, path.basename(file)),
};
});
for (const file of fileMap) {
await fs.copyFile(file.src, file.dest);
console.log(`Copied ${path.basename(file.dest)}`);
}
} catch (err) {
console.error("JS build failed:", err);
process.exit(2);
}
}
async function build() {
console.log("Starting JS build...");
await minifyFiles();
await coppyFromNodemodules();
console.log("JS build complete!");
}
build();
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

+62
View File
@@ -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
}
-5
View File
@@ -1,5 +0,0 @@
package components
templ Hello() {
<h1>Hello World</h1>
}
+1
View File
@@ -8,6 +8,7 @@ templ ImageTile(src string) {
src={ src } src={ src }
srcset={ images.SourceSet(src) } srcset={ images.SourceSet(src) }
fetchpriority="high" fetchpriority="high"
loading="eager"
class="row-start-1 col-start-1 pointer-events-none w-full h-96 object-cover" class="row-start-1 col-start-1 pointer-events-none w-full h-96 object-cover"
/> />
<div class="row-start-1 col-start-1"> <div class="row-start-1 col-start-1">
+34
View File
@@ -0,0 +1,34 @@
package components
// Modal renders a modal dialog.
//
// The <dialog> itself must never be an htmx swap target: swapping its
// innerHTML would replace the wrapper below (centering + close button) and
// break the layout. Content is swapped into the inner element with id
// "<id>-content" instead.
//
// In htmx 4 "htmx:after:swap" fires on the requesting element, which lives
// outside this dialog (e.g. gallery grid buttons). The listener is therefore
// attached document-wide via the bare hx-on form with "from:body" (the
// hx-on:<event> form cannot carry trigger specs: attribute names may not
// contain spaces) and guarded by ctx.target so only swaps into this dialog's
// content open it.
templ Modal(id string) {
<dialog
id={ id }
class="m-auto backdrop:bg-black/90 bg-transparent p-0 w-full h-full max-w-none max-h-none"
hx-on="htmx:after:swap from:body -> if (!this.open && this.contains(ctx.target)) this.showModal()"
>
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
<button
class="absolute right-5 top-5 z-10 p-2 text-white"
hx-on:click="this.closest('dialog').close()"
>
X
</button>
<div id={ id + "-content" } class="w-full h-full flex items-center justify-center overflow-hidden">
{ children... }
</div>
</div>
</dialog>
}
+51 -13
View File
@@ -1,32 +1,72 @@
package layouts package layouts
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate" import (
import "strings" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"strings"
)
templ Base(title string) { templ head(cfg config.Cfg, title string) {
<!DOCTYPE html>
<html lang={ translate.Language(ctx) }>
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="htmx-config" content={ `{"noSwap": [204, 304, "4xx", "5xx"], "preload": {"boostEvent": "mouseover"}}` }/>
<title>{ title }</title> <title>{ title }</title>
<link rel="icon" type="image/png" href="/favicon.png"/> <link rel="icon" type="image/png" href="/favicon.png"/>
<link fetchpriority="high" href="/css/output.css" rel="stylesheet"/> <link fetchpriority="high" href="/css/output.css" rel="stylesheet"/>
<script fetchpriority="low" src="/js/htmx.min.js"></script> <script src="/js/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-preload@2.1.2" integrity="sha384-PRIcY6hH1Y5784C76/Y8SqLyTanY9rnI3B8F3+hKZFNED55hsEqMJyqWhp95lgfk" crossorigin="anonymous"></script> <script defer src="/js/hx-head.min.js"></script>
<script defer src="/js/hx-preload.min.js"></script>
<script defer src="/js/preload-links.min.js"></script>
<script defer src={ cfg.Tracking.ScriptSrc } data-website-id={ cfg.Tracking.WebsiteID } data-performance={ cfg.Tracking.PerformanceEnabled }></script>
if cfg.Tracking.RecorderSrc != "" {
<script defer src={ cfg.Tracking.RecorderSrc } data-website-id={ cfg.Tracking.WebsiteID }></script>
}
for _, h := range templer.GetHeader(ctx) {
@templ.Raw(h)
}
</head> </head>
<body class="bg-black dark" hx-boost="true" hx-target="main" hx-ext="preload"> }
templ Base(cfg config.Cfg, title string) {
<!DOCTYPE html>
<html lang={ translate.Language(ctx) }>
@head(cfg, title)
<body class="bg-black dark" hx-boost:inherited="true" hx-target:inherited="body">
@body() {
{ children... }
}
</body>
</html>
}
templ Page(cfg config.Cfg, title string) {
@head(cfg, title)
@body() {
{ children... }
}
}
templ Component() {
for _, h := range templer.GetHeader(ctx) {
@templ.Raw(h)
}
{ children... }
}
templ body() {
<header <header
class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start" class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"
> >
<a class="text-white text-2xl" href={ "/" + translate.Path(ctx, "") }>schreifuchs.ch</a> <a class="text-white text-2xl" href={ translate.Path(ctx, "") } hx-preload="mouseover" preload-images="true">schreifuchs.ch</a>
<nav class="flex gap-2 p-2"> <nav class="flex gap-2 p-2">
for _, lang := range translate.SupportedLanguages(ctx) { for _, lang := range translate.SupportedLanguages(ctx) {
<a <a
href={ "/" + translate.PathForLang(ctx, lang, strings.Split(templer.Path(ctx), "/")...) } href={ translate.PathForLang(ctx, lang, strings.Split(templer.Path(ctx), "/")...) }
hreflang={ lang } hreflang={ lang }
data-no-translate data-no-translate
hx-preload="mouseover"
class="text-white hover:underline uppercase" class="text-white hover:underline uppercase"
> >
{ lang } { lang }
@@ -37,6 +77,4 @@ templ Base(title string) {
<main class="text-white"> <main class="text-white">
{ children... } { children... }
</main> </main>
</body>
</html>
} }
+29 -6
View File
@@ -2,27 +2,50 @@ package layouts
import ( import (
"context" "context"
"fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"github.com/a-h/templ" "github.com/a-h/templ"
) )
// Render renders a page. If the query paramenter "c" is set type Layouts struct {
cfg config.Cfg
}
func New(cfg config.Cfg) *Layouts {
return &Layouts{
cfg: cfg,
}
}
// RenderPage renders a page. If the query paramenter HX-Request is set
// the component gets rendered without the layout // the component gets rendered without the layout
func Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) { func (l *Layouts) RenderPage(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) {
withLayout := !r.URL.Query().Has("c") title := templer.GetTitle(ctx, "Schreifuchs")
fmt.Println("layout: ", withLayout)
slog.Debug("HX-Request", "value", r.Header.Get("HX-Request")) slog.Debug("HX-Request", "value", r.Header.Get("HX-Request"))
if r.Header.Get("HX-Request") != "true" { if r.Header.Get("HX-Request") != "true" {
ctx = templ.WithChildren(ctx, component) ctx = templ.WithChildren(ctx, component)
component = Base("hello") component = Base(l.cfg, title)
} else {
ctx = templ.WithChildren(ctx, component)
component = Page(l.cfg, title)
} }
if err := component.Render(ctx, w); err != nil { if err := component.Render(ctx, w); err != nil {
slog.Error("error while rendering component", "err", err, "ctx", ctx) slog.Error("error while rendering component", "err", err, "ctx", ctx)
} }
} }
// RenderComponent renders a component alone.
func (l *Layouts) RenderComponent(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) {
ctx = templ.WithChildren(ctx, component)
component = Component()
if err := component.Render(ctx, w); err != nil {
slog.Error("error while rendering component", "err", err, "ctx", ctx)
}
}
+127
View File
@@ -0,0 +1,127 @@
package pages
import (
"fmt"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/templer"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/components"
)
const detailsID = "imgs-details"
// detailsContentID is the inner element of the modal that htmx swaps into.
// It is defined by components.Modal and must not be the dialog itself.
const detailsContentID = detailsID + "-content"
templ GallerySet(g gallery.Set) {
<div class="p-5">
@components.Modal(detailsID) {
// Placeholder: only visible until the first image is swapped in.
// prev/next are nil because relative links would be resolved
// against the gallery index, not the image page.
if len(g.SM.Images) > 0 {
@GalleryImage(g.SM.Images[0].UID, nil, nil)
}
}
<div
class="grid gap-5 grid-cols-1 lg:hidden"
>
for _,img := range g.SM.Images {
<button
hx-get={ templer.RelativePath(ctx, "./"+img.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
class="block w-full cursor-pointer"
>
<img
src={ "/images/" + img.UID + "?w=600" }
loading="lazy"
class="w-full"
alt="Gallery item"
/>
</button>
}
</div>
<div class="hidden lg:block 3xl:hidden">
@Gallery(g.SM)
</div>
<div class="hidden 3xl:block 6xl:hidden">
@Gallery(g.LG)
</div>
<div class="hidden 6xl:block">
@Gallery(g.XLG)
</div>
</div>
}
// Define a templ script to hold complex post-load behavior
templ GalleryImage(image string, next *gallery.Image, prev *gallery.Image) {
<link rel="preload" href={ "/images/" + image + "?w=800" } as="image"/>
<div class="relative w-10/12 h-10/12 flex items-center justify-center overflow-hidden ">
<!-- 1. Preloaded Base Image (w=800) -->
<img
src={ "/images/" + image + "?w=800" }
alt=""
class="absolute inset-0 w-full h-full object-contain"
/>
<!-- 2. Responsive Image Layer (Overlaid on top) -->
<img
src={ "/images/" + image + "?w=800" }
srcset={ "/images/" + image + "?w=400 400w, /images/" + image + "?w=800 800w, /images/" + image + "?w=1200 1200w, /images/" + image + "?w=1600 1600w" }
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
alt="Image description"
onload="this.decode().then(() => {this.classList.remove('opacity-0')}).catch(() => {})"
class="absolute inset-0 w-full h-full object-contain transition-opacity duration-900 opacity-0"
/>
<!-- 3. Navigation Controls -->
if prev != nil {
<button
hx-get={ templer.RelativePath(ctx, "../"+prev.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
hx-preload="mousedown"
class="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 text-white"
>
&lt;
</button>
}
if next != nil {
<button
hx-get={ templer.RelativePath(ctx, "../"+next.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
hx-preload="mouseover"
class="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 text-white"
>
&gt;
</button>
}
</div>
}
templ Gallery(g gallery.Gallery) {
<div
class="grid gap-5"
style={ fmt.Sprintf("grid-template-columns: repeat(%d, 1fr); grid-template-rows: repeat(%d, auto);", g.GridWith, g.GridHeight) }
>
for _, img := range g.Images {
<div
style={ fmt.Sprintf("grid-column: %d / span %d; grid-row: %d / span %d;aspect-ratio: %d / %d;", img.X+1, img.Width, img.Y+1, img.Height, img.Width, img.Height) }
class="overflow-hidden shadow-sm"
>
<button
hx-get={ templer.RelativePath(ctx, "./"+img.UID) }
hx-target={ "#" + detailsContentID }
hx-swap="innerHTML"
>
<img
src={ "/images/" + img.UID + "?w=800" }
loading="lazy"
class="w-full h-full object-cover"
alt="Gallery item"
/>
</button>
</div>
}
</div>
}
+5 -1
View File
@@ -9,7 +9,11 @@ import (
templ Home(pages []page.PageHeader) { templ Home(pages []page.PageHeader) {
for _, page := range pages { for _, page := range pages {
@components.ImageTile("/images/" + page.CoverImageUID) { @components.ImageTile("/images/" + page.CoverImageUID) {
<a class="flex items-center justify-center h-full " href={ translate.Path(ctx, page.UID) } preload="mouseover"> <a
class="flex items-center justify-center h-full "
href={ translate.Path(ctx, "/"+page.UID) }
hx-preload="mouseover"
>
<h2 <h2
class="text-3xl font-bold text-white hover:underline text-shadow-lg" class="text-3xl font-bold text-white hover:underline text-shadow-lg"
> >
File renamed without changes.
+76
View File
@@ -0,0 +1,76 @@
/**
* @file preload-links.js
* @description HTMX helper to extract <link rel=preload> and image hints from fetched HTML responses.
* @generated By opencode (AI Agent)
* @model gemini-3-flash-preview
* @date Tue Sep 29 2026
*/
(function () {
const originalFetch = window.fetch;
window.fetch = function (input, init) {
const responsePromise = originalFetch.apply(this, arguments);
let method = init?.method;
if (!method && input instanceof Request) method = input.method;
method = (method || "GET").toUpperCase();
responsePromise
.then((response) => {
const contentType = response.headers.get("content-type") || "";
if (method !== "GET" || !contentType.includes("text/html")) return;
return response
.clone()
.text()
.then((html) => {
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll('link[rel="preload"]').forEach((link) => {
const href = link.getAttribute("href");
if (!href) return;
if (
document.head.querySelector(
`link[rel="preload"][href="${href}"]`,
)
)
return;
const newLink = document.createElement("link");
newLink.rel = "preload";
newLink.href = href;
["as", "type", "media", "fetchpriority", "crossorigin"].forEach(
(attr) => {
const val = link.getAttribute(attr);
if (val) newLink.setAttribute(attr, val);
},
);
document.head.appendChild(newLink);
});
doc.querySelectorAll("img[src]").forEach((img) => {
const href = img.getAttribute("src");
if (!href) return;
if (
document.head.querySelector(
`link[rel="preload"][href="${href}"][as="image"]`,
)
)
return;
const newLink = document.createElement("link");
newLink.rel = "preload";
newLink.as = "image";
newLink.href = href;
document.head.appendChild(newLink);
});
});
})
.catch(() => {});
return responsePromise;
};
})();
+1 -1
View File
@@ -1,2 +1,2 @@
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{positLine truncated @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-duration:initial;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pLine truncated
+1 -1
View File
@@ -1 +1 @@
var htmx=function(){"use strict";const Q={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=dn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.8"};Q.onLoad=V;Q.process=Ft;Q.on=xe;Q.off=be;Q.trigger=ae;Q.ajax=Ln;Q.find=f;Q.findAll=x;Q.closest=g;Q.remove=_;Q.addClass=K;Q.removeClass=G;Q.toggleClass=W;Q.takeClass=Z;Q.swap=ze;Q.defineExtension=_n;Q.removeExtension=zn;Q.logAll=j;Q.logNone=$;Q.parseInterval=d;Q._=e;const n={addTriggerHandler:St,bodyContains:se,canAccessLocalStorage:X,findThisElement:Se,filterValues:yn,swap:ze,hasAttribute:s,getAttributeValue:a,getClosestAttributeValue:ne,getClosestMatch:q,getExpressionVars:Rn,getHeaders:mn,getInputValues:dn,getInternalData:oe,getSwapSpecification:bn,getTriggerSpecs:st,getTarget:Ee,makeFragment:D,mergeObjects:le,makeSettleInfo:Sn,oobSwap:Te,querySelectorExt:ue,settleImmediately:Yt,shouldCancel:ht,triggerEvent:ae,triggerErrorEvent:fe,withExtensions:Vt};const de=["get","post","put","delete","patch"];const R=de.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function ee(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function a(e,t){return ee(e,t)||ee(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function te(){return document}function y(e,t){return e.getRootNode?e.getRootNode({composed:t}):te()}function q(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=a(t,n);const o=a(t,"hx-disinherit");var i=a(t,"hx-inherit");if(e!==t){if(Q.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function ne(t,n){let r=null;q(t,function(e){return!!(r=o(t,ce(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function A(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function L(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function N(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=te().createElement("script");ie(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Q.config.inlineScriptNonce){t.nonce=Q.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function I(e){Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){H(e)}finally{e.remove()}}})}function D(e){const t=e.replace(/<head(\s[^>]*)?>[\s\S]*?<\/head>/i,"");const n=A(t);let r;if(n==="html"){r=new DocumentFragment;const i=L(e);N(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=L(t);N(r,i.body);r.title=i.title}else{const i=L('<body><template class="internal-htmx-wrapper">'+t+"</template></body>");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){if(Q.config.allowScriptTags){I(r)}else{r.querySelectorAll("script").forEach(e=>e.remove())}}return r}function re(e){if(e){e()}}function t(e,t){return Object.protoLine truncated var htmx=(()=>{const e={parse(t){if(!t)return{};if(t.startsWith("{"))return JSON.parse(t);let r=/(?:"([^"]+)"|'([^']+)'|([^\s,:]+))(?:\s*:\s*(?:"([^"]*)"|'([^']*)'|<((?:[^/]|\/(?!>))+)\/>|([^\s,]+)))?(?=\s|,|$)/g,i={};for(let s of t.matchAll(r)){let[,t,r,n,o,a,l,h]=s,c=t??r??n,u=(o??a??l??h??"true").trim();try{u=JSON.parse(u)}catch{}let d=n?.includes("."),f=d?c.split(".").reduceRight((e,t)=>({[t]:e}),u):{[c]:u};e.merge(f,i)}return i},split:e=>e.split(/,(?![^\[]*\])(?![^(]*\))(?![^<]*\/>)(?=(?:[^"']|"[^"]*"|'[^']*')*$)/),merge(t,r){"string"==typeof t&&(t=e.parse(t));for(let[i,s]of Object.entries(t)){if(["__proto__","constructor","prototype"].includes(i))continue;let t=s?.constructor===Object,n=r[i]?.constructor===Object;t&&n?e.merge(s,r[i]):r[i]=s}return r}};class t{#e=null;#t=[];admit(e,t,r){if(!this.#e)return this.#e={strategy:e,abort:r},"run";if("replace"===e||"abort"!==e&&"abort"===this.#e.strategy)return this.#t=[],this.#e.abort?.(),this.#e={strategy:e,abort:r},"run";if("queue all"===e)this.#t.push(t);else if("queue last"===e)this.#t=[t];else{if("abort"===e||"drop"===e||0!==this.#t.length)return"dropped";this.#t.push(t)}return"queued"}continue(){this.#e=null,this.#t.shift()?.()}abort(){this.#e?.abort?.()}}return new class{#r=e;#i=new Map;#s="";#n=new Set;_loc=window.location;#o;#a=Function;#l=Object.getPrototypeOf(async function(){}).constructor;#h={createHTML:e=>e,createScript:e=>e};#c;#u="a,form";#d=["get","post","put","patch","delete","query"];#f;#m;#p;#g;#x;constructor(){this.#b(),this.#y(),this.#c=this.#S("[hx-action],[hx-get],[hx-post],[hx-put],[hx-patch],[hx-delete],[hx-query]"),this.#f=(new XPathEvaluator).createExpression(`.//*[@*[${this.#v("hx-on").map(e=>`starts-with(name(), "${e}")`).join(" or ")}]]`),this.#o={HCON:e,attributeValue:this.#E.bind(this),parseTriggerSpecs:this.#w.bind(this),determineMethodAndAction:this.#A.bind(this),createRequestContext:this.#T.bind(this),collectFormData:this.#C.bind(this),getAttributeObject:this.#q.bind(this),insertContent:this.#N.bind(this),morph:this.#I.bind(this),isSoftMatch:this.#M.bind(this),initSecurity:(e,t,r)=>{e&&(this.#h=e),t&&(this.#a=t),r&&(this.#l=r)},onTrigger:this.#O.bind(this),htmxProp:this.#H.bind(this),triggerHtmxEvent:this.#L.bind(this),executeJavaScript:this.#k.bind(this)};let t=()=>this.initialize();"loading"===document.readyState?document.addEventListener("DOMContentLoaded",t):setTimeout(t)}#b(){this.version="4.0.0",this.config={logAll:!1,prefix:"data-hx-",transitions:!1,history:!0,mode:"same-origin",defaultSwap:"innerHTML",defaultFocusScroll:!1,indicatorClass:"htmx-indicator",requestClass:"htmx-request",includeIndicatorCSS:!0,defaultTimeout:6e4,extensions:"",morphIgnore:["data-htmx-powered"],morphSkip:"[hx-morph-skip]",morphSkipChildren:"[hx-morph-skip-children]",morphScanLimit:10,noSwap:[204,304],implicitInheritance:!1,defaultSettleDelay:1,allowEmptySwapAfterOOB:!1};let t=document.querySelector('meta[name="htmx-config"]');t&&e.merge(t.content,this.config),this.#s=this.config.extensions}#y(){if(!1!==this.config.includeIndicatorCSS){let e=this.config.indicatorClass,t=this.config.requestClass,r=new CSSStyleSheet;r.replaceSync(`.${e}{opacity:0;visibility: hidden} .${t} .${e}, .${t}.${e}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`),document.adoptedStyleSheets=[...document.adoptedStyleSheets,r]}}registerExtension(e,t){return!(this.#s&&!this.#s.split(/,\s*/).includes(e))&&(!this.#n.has(e)&&(this.#n.add(e),t.init&&t.init(this.#o),void Object.entries(t).forEach(([e,t])=>{this.#i.get(e)?.push(t)||this.#i.set(e,[t])})))}#V(e){let t=this.config.prefix;return!e.closest||null!=e.closest("[hx-ignore]")||t&&null!=e.closest(`[${t}ignore]`)}#P(e,t){let r=this.config.prefix;return e.getAttribute(t)??(r?e.getAttribute(t.replace("hx-",r)):null)}#R(e,t){let r=this.config.prefix&&t.replace("hx-",this.config.prefix);return e.hasAttribute(t)?t:r&&e.hasAttribute(r)?r:null}#S(e){return this.#v(e).join(",")}#v(e){let t=[e];return this.config.prefix&&t.push(e.replaceAll("hx-",this.config.prefix)),t}#j(e,t){let r=[...e.querySelectorAll?.(t)??[]];return e.matches?.(t)&&r.unshift(e),r}#B(e){return"before"===e?"beforebegin":"after"===e?"afterend":"prepend"===e?"afterbegin":"append"===e?"beforeend":e}#F(e,t){let r=[];return this.#E(e,t,void 0,(e,t)=>{e?.split(/\s*[,:]\s*/).includes("this")&&r.push(t)}),r}#E(e,t,r,i){t=this.#z(t);let s=this.#z(":inherited"),n=this.#z(":append"),o=this.#P(e,t)??this.#P(e,t+s);if(null!=o)return i?i(o,e):o;let a=CSS.escape(this.config.implicitInheritance?t:t+s),l=CSS.escape(t+s+n),h=this.#S(`[${a}],[${l}]`),c=this.#R(e,t+n)??this.#R(e,t+s+n);if(c){let r=e.getAttribute(c),s=e.parentNode?.closest?.(h);if(i&&i(r,e),s){let e=this.#E(s,t,void 0,i);return e?(e+","+r).replace(/[{}]/g,""):r}return r}let u=e.parentNode?.closest?.(h);return u?(o=this.#E(u,t,void 0,i),!i&&o&&this.config.implicitInheritance&&this.#_(e,"htmx:after:implicitInheritance",{elt:e,name:t,parent:u}),o):r}#w(t){return e.split(t).flatMap(t=>{let[,r,i]=t.maLine truncated
+1
View File
@@ -0,0 +1 @@
!function(){let e;function t(e){let t=document.createElement(e.tagName);for(const r of e.attributes)t.setAttribute(r.name,r.value);return t.textContent=e.textContent,"LINK"===e.tagName&&"stylesheet"===e.rel?new Promise(e=>{t.onload=e,t.onerror=e,document.head.appendChild(t)}):"SCRIPT"!==e.tagName||!e.src||e.async||e.defer?(document.head.appendChild(t),e._preloadHint&&t.addEventListener("load",()=>e._preloadHint.remove(),{once:!0}),null):new Promise((e,r)=>{t.onload=e,t.onerror=r,document.head.appendChild(t)})}async function r(r,d){if(r&&r.indexOf("<head")>-1){const o=document.createElement("html");let a=r.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim,"").match(/(<head(\s[^>]*>|>)([\s\S]*?)<\/head>)/im);if(a){let r=[],n=[],h=[],s=[],m=[];o.innerHTML=a;let l=o.querySelector("head"),i=document.head;if(null==l)return[];let c=new Map;for(const e of l.children)c.set(e.outerHTML,e);let u=e.attributeValue(l,"hx-head")||d;for(const t of i.children){let r=c.has(t.outerHTML),d="re-eval"===t.getAttribute("hx-head"),o="true"===e.attributeValue(t,"hx-preserve");r||o?d?n.push(t):(c.delete(t.outerHTML),h.push(t)):"append"===u?d&&(n.push(t),s.push(t)):!1!==htmx.trigger(document.body,"htmx:head:before:remove",{headElement:t})&&n.push(t)}s.push(...c.values());for(const e of s)if("SCRIPT"===e.tagName&&e.defer){if(m.push(e),e.src){let t=document.createElement("link");t.rel="module"===e.type?"modulepreload":"preload",t.as="script",t.href=e.src,document.head.appendChild(t),e._preloadHint=t}}else!1!==htmx.trigger(document.body,"htmx:head:before:add",{headElement:e})&&(await t(e),r.push(e));for(const e of n)!1!==htmx.trigger(document.body,"htmx:head:before:remove",{headElement:e})&&i.removeChild(e);return htmx.trigger(document.body,"htmx:head:after:merge",{added:r,kept:h,removed:n}),m}}return[]}htmx.registerExtension("hx-head",{init:t=>{e=t},htmx_before_response:(e,t)=>{let d=t.ctx,o=d.target===document.body?"merge":"append";if(htmx.trigger(document.body,"htmx:head:before:merge",t)){let e=d.response.raw.text.bind(d.response.raw);d.response.raw.text=async()=>{let t=await e();return d._deferredHeadScripts=await r(t,o),t}}},htmx_after_swap:(e,r)=>{for(const e of r.ctx._deferredHeadScripts||[])t(e)},htmx_history_cache_before_restore:(e,t)=>{t.head&&(t.ready=r(t.head,"merge").then(e=>{t._deferredHeadScripts=e}))},htmx_history_cache_after_restore:(e,r)=>{for(const e of r._deferredHeadScripts||[])t(e)}})}();
+1
View File
@@ -0,0 +1 @@
(()=>{let e;htmx.registerExtension("preload",{init:t=>{e=t},htmx_after_init:t=>{!function(t){let r=e.attributeValue(t,"hx-preload");if(null==r&&!t._htmx?.boosted)return;let o=[],a=5e3;if(r){let t=e.parseTriggerSpecs(r);if(0===t.length)return;for(const e of t)o.push(e.name),e.timeout&&(a=htmx.parseInterval(e.timeout))}else{let r=t._htmx?.boosted&&"A"===t.tagName,l=null!=e.attributeValue(t,"hx-get");if(!r&&!l)return;if(r&&!1===htmx.config?.preload?.autoBoost)return;htmx.config?.preload?.boostTimeout&&(a=htmx.parseInterval(htmx.config.preload.boostTimeout)),o.push(htmx.config?.preload?.boostEvent||"mousedown"),o.push("touchstart")}let l=async r=>{let{method:o}=e.determineMethodAndAction(t,r);if("GET"!==o)return;if(t._htmx?.preload)return;let l=e.createRequestContext(t,r),n=t.form||t.closest("form"),i=e.collectFormData(t,n,r.submitter),h=e.getAttributeObject(t,"hx-vals",e=>{for(let t in e)i.set(t,e[t])});h&&await h;let s=l.request.action.replace?.(/#.*$/,""),m=new URLSearchParams(i);m.size&&(s+=(/\?/.test(s)?"&":"?")+m);let p=new URL(s,location.href);t._htmx.preload={prefetch:fetch(s,l.request),action:p.origin===location.origin?p.pathname+p.search:p.href,expiresAt:Date.now()+a};try{await t._htmx.preload.prefetch}catch(e){delete t._htmx.preload}};for(let e of o)t.addEventListener(e,l,{passive:!0});t._htmx.preloadListener=l,t._htmx.preloadEvents=o}(t)},htmx_before_request:(e,t)=>{let{ctx:r}=t;if(e._htmx?.preload&&e._htmx.preload.action===r.request.action&&Date.now()<e._htmx.preload.expiresAt){let t=e._htmx.preload.prefetch;r.fetch=()=>t,delete e._htmx.preload}else e._htmx&&delete e._htmx.preload},htmx_before_cleanup:e=>{if(e._htmx?.preloadListener)for(let t of e._htmx.preloadEvents)e.removeEventListener(t,e._htmx.preloadListener)}})})();
+1
View File
@@ -0,0 +1 @@
(function(){const d=window.fetch;window.fetch=function(o,s){const c=d.apply(this,arguments);let r=s?.method;return!r&&o instanceof Request&&(r=o.method),r=(r||"GET").toUpperCase(),c.then(i=>{const u=i.headers.get("content-type")||"";if(!(r!=="GET"||!u.includes("text/html")))return i.clone().text().then(f=>{const l=new DOMParser().parseFromString(f,"text/html");l.querySelectorAll('link[rel="preload"]').forEach(n=>{const t=n.getAttribute("href");if(!t||document.head.querySelector(`link[rel="preload"][href="${t}"]`))return;const e=document.createElement("link");e.rel="preload",e.href=t,["as","type","media","fetchpriority","crossorigin"].forEach(a=>{const h=n.getAttribute(a);h&&e.setAttribute(a,h)}),document.head.appendChild(e)}),l.querySelectorAll("img[src]").forEach(n=>{const t=n.getAttribute("src");if(!t||document.head.querySelector(`link[rel="preload"][href="${t}"][as="image"]`))return;const e=document.createElement("link");e.rel="preload",e.as="image",e.href=t,document.head.appendChild(e)})})}).catch(()=>{}),c}})();